From 2515e7b97764c492fa39872edeae7b04fc9ef7de Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 11 May 2026 12:17:19 +0200 Subject: [PATCH 1/8] =?UTF-8?q?spec:=20openbuilt-versioning=20=E2=80=94=20?= =?UTF-8?q?draft/publish/rollback=20+=20manifest=20snapshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenSpec change artifacts for spec #6 of the OpenBuilt 9-spec chain. Adds declarative ApplicationVersion schema + on_transition snapshot action (ADR-031, declarative-first with listener fallback per §Exceptions(1) — same OQ-1 pattern as bootstrap-openbuilt), plus the UI surface for Publish, version history, audit-clean rollback, and client-side side-by-side diff (jsdiff). Capabilities: - NEW openbuilt-version-snapshots (6 requirements) - MODIFIED openbuilt-application-register (currentVersion + snapshot action) - MODIFIED openbuilt-runtime (Publish action, status badge, VersionHistory.vue, RollbackConfirmModal, ManifestDiff.vue) openspec validate openbuilt-versioning --strict: PASS. --- .../openbuilt-versioning/.openspec.yaml | 2 + .../changes/openbuilt-versioning/design.md | 407 ++++++++++++++++++ .../changes/openbuilt-versioning/proposal.md | 141 ++++++ .../openbuilt-application-register/spec.md | 51 +++ .../specs/openbuilt-runtime/spec.md | 152 +++++++ .../specs/openbuilt-version-snapshots/spec.md | 187 ++++++++ .../changes/openbuilt-versioning/tasks.md | 130 ++++++ 7 files changed, 1070 insertions(+) create mode 100644 openspec/changes/openbuilt-versioning/.openspec.yaml create mode 100644 openspec/changes/openbuilt-versioning/design.md create mode 100644 openspec/changes/openbuilt-versioning/proposal.md create mode 100644 openspec/changes/openbuilt-versioning/specs/openbuilt-application-register/spec.md create mode 100644 openspec/changes/openbuilt-versioning/specs/openbuilt-runtime/spec.md create mode 100644 openspec/changes/openbuilt-versioning/specs/openbuilt-version-snapshots/spec.md create mode 100644 openspec/changes/openbuilt-versioning/tasks.md diff --git a/openspec/changes/openbuilt-versioning/.openspec.yaml b/openspec/changes/openbuilt-versioning/.openspec.yaml new file mode 100644 index 00000000..81cd71fe --- /dev/null +++ b/openspec/changes/openbuilt-versioning/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-11 diff --git a/openspec/changes/openbuilt-versioning/design.md b/openspec/changes/openbuilt-versioning/design.md new file mode 100644 index 00000000..ec5bed95 --- /dev/null +++ b/openspec/changes/openbuilt-versioning/design.md @@ -0,0 +1,407 @@ +## Context + +This is spec #6 of the OpenBuilt 9-spec chain (ADR-032). Spec #1 +(`bootstrap-openbuilt`) declared the Application lifecycle +(`draft → published → archived`) via `x-openregister-lifecycle` and +the foundational schemas; THIS spec ships the **version snapshot +mechanism** and the **draft / publish / rollback UX** that make the +lifecycle actually useful for citizen developers. + +The architectural commitment from ADR-031 holds: the snapshot data +model (`ApplicationVersion`) and the snapshot-on-publish action are +**declarative** OpenRegister metadata. The UI surface — the Publish +button, the version-history panel, the rollback modal, the diff +component — is unavoidably code. The split mirrors bootstrap-openbuilt +(declarative lifecycle + thin-glue PHP + Vue UI) and applies ADR-032's +thin-glue exception for `kind: mixed`. + +The runtime workaround documented in bootstrap-openbuilt's Decision 4 +(`options.fetcher` redirect for `useAppManifest`) is **not** affected +by this spec — versioning operates on the stored manifest blob in OR, +not on the runtime loader path. + +## Goals / Non-Goals + +**Goals** + +- Ship the `ApplicationVersion` schema declaratively, with the + snapshot-on-publish action declared as `x-openregister-lifecycle.on_transition` + metadata (or, per ADR-031 §Exceptions(1), as a single PHP listener + if OR's engine lacks the hook — same OQ-1 fallback as + bootstrap-openbuilt). +- Extend the Application schema with a `currentVersion` reference, + kept in sync by the same lifecycle action. +- Ship the draft-vs-published indicator, the Publish action, + `VersionHistory.vue`, the rollback action with confirmation modal, + and a client-side `ManifestDiff.vue` component. +- Ship a thin `diffVersions` endpoint that returns two manifest blobs + in one round-trip so the client diff component doesn't double-fetch. + +**Non-Goals** + +- JSON Patch / delta compression for snapshots (Decision 1 below + picks full-blob copies for v1; chain it later if storage bites). +- Server-side diff rendering or PHP diff libraries (Decision 5). +- Retention caps / time-based expiry / "keep last N" (Decision 4). +- Branching, named release channels, parallel drafts (out of scope; + if needed, spec it as a follow-on in the chain). +- Cross-Application snapshot inheritance / template-from-snapshot + (that's chain spec #8 / marketplace territory). +- A standalone visual diff editor with in-place merge / patch + application (out of scope; v1 is read-only diff visualisation). + +## Decisions + +### Decision 1 — Snapshot data shape: full manifest blob copy (not JSON Patch chain) + +`ApplicationVersion.manifest` SHALL hold a **full deep-copy** of the +Application's manifest at snapshot time, not a JSON Patch against the +previous snapshot. + +**Alternatives considered** + +- *JSON Patch chain (RFC 6902 deltas)*. Compact storage, but + reconstructing version N requires applying N-1 patches. Adds an + unavoidable PHP service class (a "patch applier") that violates + ADR-031's declarative-first stance. Failure modes (a corrupt patch + in the middle of the chain) are recoverable in theory but ugly in + practice; for a citizen-developer audience the simpler "every + snapshot is a self-contained blob" mental model wins. +- *Hybrid (every N versions a full blob, deltas between)*. Same + reconstruction complexity, same service-class objection. Defer. + +Manifest blobs are kilobyte-scale JSON. At the expected publish +cadence (a handful per day per app on the high end) full-blob +retention costs are negligible compared to e.g. OR's audit-trail +storage. If a future spec shows storage pressure, JSON Patch chains +can be introduced behind a `compaction` flag without breaking the +schema contract (the `manifest` field stays full-blob; a sidecar +field would hold the patched form). + +### Decision 2 — Lifecycle hook: declarative first, PHP listener fallback (ADR-031) + +The snapshot-on-publish action SHALL be declared as +`x-openregister-lifecycle.on_transition` metadata on the +`Application` schema's `draft → published` edge. The action SHALL +create the `ApplicationVersion` row, update +`Application.currentVersion`, and (per Decision 3 below) reset the +Application's `status` to `draft`. + +If OR's lifecycle engine cannot yet express an `on_transition` action +that creates a sibling object (and updates the parent), the spec MAY +fall back to a single PHP listener +`lib/Listener/ApplicationVersionSnapshotListener.php` subscribed to +OR's `ObjectLifecycleTransitionedEvent`. This is identical in pattern +to bootstrap-openbuilt's `BuiltAppRouteSyncListener` exception (its +OQ-1) and is permitted by ADR-031 §Exceptions(1). The implementer +SHALL: + +- attempt the declarative path first and verify with an integration + test that the action fires; +- only on demonstrated engine gap, ship the listener; +- if the listener path is taken, file an OR-side issue requesting + the missing hook (referencing this spec and bootstrap-openbuilt's + OQ-1) so the listener can be removed when the engine catches up; +- record the chosen path in this change's `hydra.json` under + `decisions[]` for self-learning. + +**Anti-pattern explicitly avoided.** This spec ships no +`VersioningService.publish()` / `SnapshotService.snapshot()` / +`ApplicationVersionManager` class. Anything that looks like a +generic versioning service is an ADR-031 review-block. + +**Alternatives considered** + +- *Always-PHP listener (skip declarative)*. Rejected. Even if the + engine gap exists today, the declarative path is the canonical + ADR-031 example we want to push OR's engine to support. Trying + declarative first surfaces the gap as an actionable OR issue + rather than hiding it behind code. +- *Inline the snapshot create in `ApplicationsController::publish`*. + Rejected. No such controller method exists (per spec #1, CRUD is + via OR REST + the lifecycle endpoint). Adding one duplicates the + lifecycle abstraction OR already owns. + +### Decision 3 — Rollback semantics: audit-clean (create new snapshot, never destroy) + +Rollback SHALL be **non-destructive**. Rolling back to +`ApplicationVersion` N SHALL: + +1. Copy N's `manifest` onto the Application's draft manifest. +2. Set the Application's `version` field to N's `version` suffixed + with a rollback marker (e.g. `+rollback` or + `-rollback-`) so that semver tooling + recognises the next publish as a new distinct version. +3. Leave the Application's `status` as `draft` (the rolled-back + manifest is now the working draft; the integrator can edit + further and republish to create a fresh `ApplicationVersion` row). +4. Leave every existing `ApplicationVersion` row untouched — + `ApplicationVersion` history is append-only. + +Republishing after a rollback produces a NEW `ApplicationVersion` +row (the snapshot-on-publish action fires as for any normal publish). +This preserves a clean audit trail: every published state in the +Application's life is represented by exactly one +`ApplicationVersion` row, in append order. + +A consequence — and a deliberate choice — is that on the `draft → +published` transition the lifecycle action ALSO resets the +Application's `status` back to `draft`. The published state is +represented by "an `ApplicationVersion` row exists and +`currentVersion` points at it", not by a flag on the Application. +This avoids the friction of "publish → must transition back to +draft manually before editing again" which would otherwise turn +the editor into a modal experience. + +**Alternatives considered** + +- *Destructive rollback (truncate history past version N)*. Rejected. + Loses the auditable record of "we tried v1.2 in production and + rolled back at 14:32" — exactly the kind of evidence a citizen + developer needs when they call support. +- *Soft-delete (mark rows past N as "superseded")*. Rejected. Adds + a state field that the UI must filter on. The append-only model + is conceptually simpler and OR's standard `@self.deleted` is + available if true deletion is ever needed. +- *Keep Application status `published` between transitions*. Rejected + per the friction argument above. The "status = `draft` always when + editable, `published` ephemerally at the moment of transition" + model matches how integrators actually work (edit → publish → edit + again). + +### Decision 4 — Retention policy: keep all versions in v1 + +`ApplicationVersion` rows SHALL NOT be auto-deleted, trimmed, or +expired by OpenBuilt in v1. Retention is unlimited. + +Rationale: + +- Manifest blobs are small (kilobytes). +- Audit-trail loss is irreversible. +- A retention cap is opt-in and easy to add later (a + `retentionPolicy` field on `Application` declaratively driving an + OR cleanup job) without breaking the v1 contract. + +If a deployment hits real storage pressure, OR's standard +`@self.deleted` soft-delete is available and can be wired to an +admin-initiated cleanup later. v1 ships without the lever. + +**Alternatives considered** + +- *Keep last N (e.g. 50)*. Rejected for v1. Imposes a choice on + every installation without evidence the choice is needed. Easy + to add as an opt-in field later. +- *Time-based (e.g. 90 days)*. Rejected for v1. Same objection. + +### Decision 5 — Diff rendering: client-side via `jsdiff` (or equivalent) + +The side-by-side diff SHALL be computed client-side. The frontend +SHALL add `jsdiff` (or an equivalent JS diff library — `diff` on +npm, `fast-diff`, etc.) to `package.json`. The `diffVersions` +endpoint returns both manifest blobs and nothing else; rendering is +the client's job. + +**Alternatives considered** + +- *Server-side diff via PHP `DiffMatchPatch` port or similar*. + Rejected. Adds a PHP dependency, a server endpoint that returns + hunks-not-text (forcing a new contract), and a tier of state the + client needs to interpret. The client can pretty-print manifests + and run a JS diff in tens of milliseconds for kilobyte blobs. +- *Use NC's existing diff display*. Investigated; NC's diff utilities + target file diffs, not JSON object diffs. Unsuitable. + +The `ManifestDiff.vue` component SHALL JSON-pretty-print both blobs +deterministically (sorted keys, stable indentation) before +diffing, so cosmetic key-order shuffles don't produce noise hunks. + +### Decision 6 — Declarative-vs-imperative split (ADR-031) + +| Candidate behaviour | Path | +|---|---| +| `ApplicationVersion` schema definition | **Declarative** — JSON in `lib/Settings/openbuilt_register.json`. No PHP class. | +| Snapshot-on-publish action | **Declarative** — `x-openregister-lifecycle.on_transition` action on the Application schema. ADR-031 §Exceptions(1) fallback to a single listener PHP class only if the engine gap is demonstrated. | +| `currentVersion` upkeep | **Declarative** — same action / listener as the snapshot. | +| Rollback (copy snapshot → draft manifest) | **Imperative — frontend** — initiated by the integrator's click; PUT against OR REST. No new server action needed because the manifest field is just an OR object property; the rollback is a normal write. | +| Manifest diff | **Imperative — frontend** — client-side `jsdiff` per Decision 5. | +| Diff endpoint | **Imperative — thin glue** — ~15 LOC PHP method that looks up two `ApplicationVersion` rows (or one + the draft Application) and returns both `manifest` blobs unwrapped. Exists only to save a round-trip; no business logic. | +| Version-history listing | **Frontend over OR REST** — no server-side wrapper. | +| Publish action button | **Frontend** — invokes the existing lifecycle endpoint declared by spec #1. | + +**Anti-patterns explicitly avoided:** + +- `VersioningService`, `SnapshotService`, `ApplicationVersionService`, + `ManifestVersionManager` — any class whose name suggests "the + service that does versioning". The lifecycle action does + versioning; the diff endpoint is glue, not a service. +- A server-side rollback endpoint. Rollback is a write to the + manifest field. OR REST already handles writes. +- Server-side diff hunks. The client renders. + +### Decision 7 — Mixed-spec rationale (ADR-032) + +Per ADR-032 this spec is `kind: mixed` because it adds: + +- a declarative schema patch (the `ApplicationVersion` schema + + the lifecycle action declaration on Application + the + `currentVersion` field on Application); +- code (the `diffVersions` controller method + route, the + `VersionHistory.vue` panel, the `ManifestDiff.vue` component, the + rollback confirmation modal under `src/modals/` per Hydra + modal-isolation gate, the editor changes for the Publish + button + status badge, and — only if Decision 2's listener + fallback is needed — `ApplicationVersionSnapshotListener.php`). + +The code surface is bounded: + +- **`ApplicationsController::diffVersions`** — ~15 LOC, mirrors + `getManifest` from spec #1. +- **`appinfo/routes.php`** — ~3 LOC route entry. +- **`VersionHistory.vue`** — ~80 LOC including template. List view + with no novel state; reads OR REST and renders rows. +- **`ManifestDiff.vue`** — ~120 LOC including template. The bulk is + pretty-print + diff rendering. +- **`RollbackConfirmModal.vue`** (under `src/modals/` per Hydra + modal-isolation gate) — ~40 LOC. +- **`ApplicationEditor.vue` modifications** — ~30 LOC added for the + Publish button, status badge, and version-history mount. +- **Optional `ApplicationVersionSnapshotListener.php`** — ~50 LOC + if Decision 2's fallback is needed. + +Total worst-case code surface is ~340 LOC across 6 files. ADR-032 +admits the thin-glue exception when "code is tightly coupled to the +declarative config and there is no clean way to ship the config +alone". That holds here: the version-history panel and Publish +button only make sense paired with the new schema + lifecycle +action; shipping them in a separate chain pays coordination cost +without removing review surface. + +If, during apply, the code surface significantly exceeds these +estimates (e.g. the diff component balloons past ~200 LOC because +the JSON pretty-printer turns out gnarly), this spec MUST be split +into `openbuilt-versioning-schemas` (config only) + +`openbuilt-versioning-ui` (code only). At that point this design +document becomes the parent record for the split. + +**Alternatives considered** + +- *Pre-emptive split into config + code chain*. Rejected. The + config is tiny (one schema + one lifecycle action + one field) + and the code without the config has nothing to render. The + natural unit of review is "versioning end-to-end works". +- *Pure-declarative*. Not possible — UI components for diff / + history / rollback inherently require code. + +## Risks / Trade-offs + +- **Risk — declarative `on_transition` action may not support + sibling-object create.** Same OR-engine-gap question as + bootstrap-openbuilt's OQ-1. Mitigation: Decision 2 documents the + ADR-031 §Exceptions(1) listener fallback; integration tests assert + the observed behaviour in either path. +- **Risk — full-blob snapshots grow storage faster than expected.** + Mitigation: manifest blobs are kilobyte-scale; if pressure + materialises, a future spec can introduce delta compression + behind a `compaction` flag without breaking the public schema + contract (Decision 1). +- **Risk — rollback marker (`+rollback` semver suffix) collides + with integrator-authored versions.** Mitigation: include a short + UUID fragment in the marker (`-rollback-<6hex>`) so + collisions are astronomically unlikely. +- **Risk — client-side `jsdiff` diff time on very large manifests.** + Mitigation: manifests are typically <50 KB; if the diff render + becomes sluggish past a threshold, throw the diff into a Web + Worker. Out of scope for v1; flagged as a known follow-on. +- **Trade-off — `status` resets to `draft` after publish (Decision 3).** + Subtle but deliberate. Means the Application's persistent + `status` no longer carries "is published right now" semantics — + that's `currentVersion != null` instead. Documented in + REQ-OBA-007 scenarios and the i18n help string. +- **Trade-off — no retention cap (Decision 4).** Storage grows + unboundedly. Acceptable for v1 given expected blob sizes and + publish cadences; opt-in retention is a future spec. + +## Migration Plan + +This is a strictly additive change layered on top of spec #1's +foundational schemas and runtime. Deployment steps: + +1. Land the change on a feature branch from `development`. +2. CI runs PHPUnit + Newman + Playwright. The integration test for + the full publish→rollback→republish cycle is the canonical + green-light signal. +3. Merge into `development`. The migration runs on next deploy via + the existing repair step (which now picks up the + `ApplicationVersion` schema declaration and the new lifecycle + action / `currentVersion` field on Application). +4. **Rollback** — disable the OpenBuilt versioning surface by + reverting this change's deltas. Existing `ApplicationVersion` + rows are harmless (no other Conduction app reads from them) and + can be removed via OR's admin UI if a clean uninstall is wanted. +5. **Data migration** — none. Existing Applications carry no + `currentVersion` and no `ApplicationVersion` rows until they + next publish. The version-history panel renders an empty state + for never-published Applications; no backfill is performed. + +## Seed Data + +Per ADR-001, every schema-introducing change ships seed data. The +seeded `hello-world` Application from spec #1 SHALL be extended with +a single seeded `ApplicationVersion` row representing its current +manifest, so that the version-history panel is non-empty on a fresh +install and the diff view has something to render in the +walkthrough. + +**Seeded `ApplicationVersion`** (in the `openbuilt/application-version` +namespace): + +- `applicationUuid`: UUID of the seeded `hello-world` Application +- `version`: `1.0.0` +- `manifest`: a deep-copy of the seeded `hello-world` Application's + manifest at install time (byte-equal) +- `publishedAt`: install timestamp +- `publishedBy`: `system` (or the repair-step actor) +- `notes`: `Seeded by OpenBuilt install — initial published version` + +The repair step (`SeedHelloWorld.php` from spec #1) SHALL be +extended to also set the Application's `currentVersion` to the +seeded `ApplicationVersion`'s UUID, and to be idempotent on this +new step (re-running the repair on a seeded install SHALL NOT +create a duplicate row). + +## Open Questions + +- **OQ-1 — `on_transition.create_relation` (or sibling-object create) + action support in OR's lifecycle engine.** Same question + bootstrap-openbuilt's OQ-1 raised. If yes by apply time, ship + declarative; if no, ship the listener and file the OR-side issue. + *Provisional decision*: try declarative first, fall back to the + listener per Decision 2. +- **OQ-2 — Rollback version-marker convention.** `+rollback` + (semver build metadata) is technically valid SemVer 2 but some + tooling treats build metadata as ignorable. Alternative: + `-rollback-<6hex>` (pre-release identifier — strictly ordered + below the base version). *Provisional decision*: use the + pre-release form so that a rolled-back-then-republished version + sorts correctly relative to the original. Settle during apply + after a 10-minute look at NC's existing semver tooling. +- **OQ-3 — Notes field UX.** The `ApplicationVersion.notes` field + exists but no UI in v1 lets the integrator fill it in. Should + the Publish button open a notes prompt before completing the + transition, or stay silent? *Provisional decision*: silent in + v1 (Publish stays one-click). A future minor spec can add the + notes prompt; the field already supports it on the data side. +- **OQ-4 — Diff component placement.** Mount `ManifestDiff.vue` as + a sibling tab in `ApplicationEditor.vue`, or as a modal opened + from the version-history rows, or both? *Provisional decision*: + sibling tab (always reachable, no modal stacking), with a + one-click "Compare with current draft" affordance on each + history row that switches to the tab pre-loaded with that pair. +- **OQ-5 — Diff library exact choice.** `jsdiff` is the + recommendation, but `diff` (npm), `fast-diff`, and the + shared-deps story (memory rule about `@conduction/nextcloud-vue`) + all deserve a 15-minute investigation during apply. *Provisional + decision*: `jsdiff` unless `nextcloud-vue` already ships a diff + utility we should reuse (the memory rule says it ships + `apexcharts` but NOT `fortawesome` — a similar audit for diff + utilities is needed). diff --git a/openspec/changes/openbuilt-versioning/proposal.md b/openspec/changes/openbuilt-versioning/proposal.md new file mode 100644 index 00000000..c7b91a23 --- /dev/null +++ b/openspec/changes/openbuilt-versioning/proposal.md @@ -0,0 +1,141 @@ +--- +kind: mixed +depends_on: [bootstrap-openbuilt] +chain: + - bootstrap-openbuilt + - openbuilt-versioning # THIS spec +--- + +## Why + +Spec #1 (`bootstrap-openbuilt`) declared the `Application` lifecycle +(`draft → published → archived`) via `x-openregister-lifecycle`, but it +did **not** ship the snapshot mechanism that makes that lifecycle +useful in practice. A citizen developer who publishes a virtual app +today has no way to: + +1. iterate safely on a published app (every save mutates the live + manifest), +2. recover a known-good version when an edit goes wrong, or +3. inspect what changed between two points in time. + +Without versioned snapshots and a draft-staging UX, the publish +button is effectively a one-way door — exactly the friction OpenBuilt +must remove for non-developers to trust it as their authoring surface. + +This spec ships the missing pieces: + +- a new declarative `ApplicationVersion` schema that snapshots an + Application's manifest at each publish, +- a draft / published indicator and a "Publish" action in the + OpenBuilt shell, +- a version-history panel and a "Roll back to version N" action, +- a side-by-side diff view between any two manifest snapshots + (current draft vs published is the default pairing). + +Per ADR-031 the snapshot data model and the snapshot-on-publish +action are declarative; the diff and version-history UI are +unavoidably code. The split is the same pattern bootstrap-openbuilt +applied and OQ-1 below tracks the same OR-engine-capability question +(`on_transition` action support) that OQ-1 in the bootstrap design +already filed against OpenRegister. + +## What Changes + +- **NEW** OpenRegister schema `openbuilt/application-version` + declared in `lib/Settings/openbuilt_register.json` with properties: + - `uuid` (string, UUID-format) + - `applicationUuid` (string, UUID-format, required — points at the + parent Application) + - `version` (string, semver pattern, required — copied from the + Application's `version` at snapshot time) + - `manifest` (object, required — full manifest blob copy; see + design.md Decision 1) + - `publishedAt` (string, ISO 8601 timestamp, required) + - `publishedBy` (string, NC user id, required) + - `notes` (string, optional — free-text changelog entry) +- **NEW** declarative `x-openregister-lifecycle.on_transition` action + on the `Application` schema's `draft → published` edge that creates + an `ApplicationVersion` record snapshotting the current manifest. If + OR's engine does not yet expose an action that can create a sibling + object on a state transition, fall back to a single + `ApplicationVersionSnapshotListener` PHP class subscribed to OR's + `ObjectLifecycleTransitionedEvent` — ADR-031 §Exceptions(1), same + OQ-1 pattern bootstrap-openbuilt already documented. +- **NEW** `currentVersion` field on the `Application` schema (string, + UUID-format, optional) — points at the most recent + `ApplicationVersion` row, kept in sync by the same lifecycle action + / listener. +- **NEW** Draft-vs-published indicator in the OpenBuilt shell's + Application list and `ApplicationEditor.vue` header. +- **NEW** "Publish" action button in `ApplicationEditor.vue` that + triggers the Application's `draft → published` lifecycle transition + via OR REST. After publishing the editor returns to the draft + manifest (Application status flips back to `draft` for the next + edit cycle — see design.md Decision 3). +- **NEW** `VersionHistory.vue` panel listing all + `ApplicationVersion` rows for the current Application (newest + first) with `version`, `publishedAt`, `publishedBy`, and `notes` + columns. +- **NEW** "Roll back to this version" action on each row in the + version-history panel. Rollback copies the chosen + `ApplicationVersion`'s `manifest` blob back onto the Application's + draft manifest and creates a new `ApplicationVersion` snapshot + pointing at the restored state (audit-clean history per + design.md Decision 3 — never destructive). +- **NEW** `ManifestDiff.vue` component rendering a client-side + side-by-side diff between two manifest blobs (defaults to current + draft vs latest published). Diff implementation is client-side + per design.md Decision 5. +- **NEW** `GET /api/applications/{slug}/versions/diff?from={uuidA}&to={uuidB}` + endpoint returning both manifest blobs unwrapped, so the client + diff component does not need two round-trips. Endpoint is thin + glue (~15 LOC) on top of OR REST. + +### Capabilities + +#### New Capabilities + +- `openbuilt-version-snapshots`: The OR-backed `ApplicationVersion` + schema, the declarative snapshot-on-publish action (or its + ADR-031-exception listener), and the version-history / + rollback / diff UI. Owns the "I can see what changed and undo a + bad publish" experience. + +#### Modified Capabilities + +- `openbuilt-application-register` — ADDED Requirements: the + `Application` schema gains a `currentVersion` field and the + `draft → published` transition gains a snapshot action. +- `openbuilt-runtime` — ADDED Requirements: the Application editor + exposes a Publish action and a draft-vs-published badge; the + shell mounts the new `VersionHistory.vue` panel and + `ManifestDiff.vue` component. + +## Impact + +- **New code** — `lib/Settings/openbuilt_register.json` (schema + patch — declarative), `src/views/VersionHistory.vue`, + `src/components/ManifestDiff.vue`, modifications to + `src/views/ApplicationEditor.vue`, a thin diff endpoint method on + `ApplicationsController.php`, a route entry in `appinfo/routes.php`. + If OR's `on_transition` action cannot create a sibling object, + one additional file `lib/Listener/ApplicationVersionSnapshotListener.php` + (ADR-031 exception, mirrors the BuiltAppRouteSyncListener + pattern from bootstrap-openbuilt). +- **External dependency** — `jsdiff` (or equivalent client-side + diff lib) added to `package.json` — chosen per design.md + Decision 5. No server-side PHP diff lib introduced. +- **OpenRegister** — adds one schema (`application-version`) and + one lifecycle action declaration to the existing OpenBuilt + register namespace. +- **No breaking changes** — additive only. Existing Applications + carry no `ApplicationVersion` rows until they next publish; the + version-history panel renders an empty state until then. The + new `currentVersion` field is optional on `Application`. +- **Foundational ADRs honoured** — ADR-022 (consume OR + abstractions — `ApplicationVersion` lives in OR, the snapshot + action is declarative), ADR-024 (manifest contract unchanged — + snapshots store the same blob shape), ADR-031 (declarative-first + — no `VersioningService` / `SnapshotService` class), ADR-032 + (kind: mixed with thin-glue exception — see `design.md`). diff --git a/openspec/changes/openbuilt-versioning/specs/openbuilt-application-register/spec.md b/openspec/changes/openbuilt-versioning/specs/openbuilt-application-register/spec.md new file mode 100644 index 00000000..2cceebff --- /dev/null +++ b/openspec/changes/openbuilt-versioning/specs/openbuilt-application-register/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: REQ-OBA-006 Application schema carries a currentVersion reference + +The `Application` schema declared in `lib/Settings/openbuilt_register.json` (REQ-OBA-001) SHALL be extended with a `currentVersion` property of type string with UUID-format. The property SHALL be optional (an Application that has never been published has no `currentVersion`). When populated, it SHALL hold the `uuid` of the most recent `ApplicationVersion` row for this Application (see capability `openbuilt-version-snapshots`, REQ-OBV-006). The schema change SHALL remain backward-compatible: existing Applications imported from spec #1 carry no `currentVersion` and SHALL continue to load, list, and edit without error. + +#### Scenario: Existing Applications remain valid without currentVersion + +- **WHEN** the OpenBuilt repair step runs an upgrade on an install + that already has seeded Applications from spec #1 +- **THEN** those Applications continue to load via OR REST +- **AND** their `currentVersion` field is absent or `null` +- **AND** the textarea editor renders them without validation + errors + +#### Scenario: currentVersion is updated atomically with the snapshot + +- **WHEN** an Application transitions from `draft` to `published` +- **THEN** the same lifecycle action that creates the + `ApplicationVersion` row also writes the new row's `uuid` into + the Application's `currentVersion` +- **AND** both writes are observed by a subsequent OR REST GET of + the Application + +### Requirement: REQ-OBA-007 Draft-to-published transition declares a snapshot action + +The `x-openregister-lifecycle` block on the `Application` schema (REQ-OBA-003) SHALL declare an `on_transition` action on the `draft → published` edge that creates a new `ApplicationVersion` row populated from the Application's current `manifest`, `version`, the actor's NC user id, and the transition timestamp; updates the Application's `currentVersion` to the new row's `uuid`; and sets the Application's `status` back to `draft` so that the next edit session continues from a draft state, while the just-created `ApplicationVersion` serves as the "published" record (see design.md Decision 3 for rationale). + +If OR's lifecycle engine cannot yet express a sibling-object create action in `on_transition`, the action MAY be implemented as a single PHP listener subscribed to `ObjectLifecycleTransitionedEvent` per ADR-031 §Exceptions(1) — mirroring the OQ-1 escape hatch bootstrap-openbuilt established. The observed behaviour SHALL be identical in either case. The implementer SHALL NOT introduce a generic `VersioningService` / `SnapshotService` class. + +#### Scenario: Declarative path emits the snapshot + +- **WHEN** OR's engine supports `on_transition.create_relation` (or + equivalent) +- **AND** an Application transitions from `draft` to `published` +- **THEN** a snapshot is created without any custom PHP listener + being invoked +- **AND** the OR audit trail records both the transition and the + snapshot create + +#### Scenario: Listener fallback produces the same outcome + +- **WHEN** OR's engine does not yet expose the sibling-create action + and the fallback `ApplicationVersionSnapshotListener` is registered +- **AND** an Application transitions from `draft` to `published` +- **THEN** the listener creates the `ApplicationVersion` row, + updates `currentVersion`, and resets the Application's `status` + to `draft` +- **AND** the resulting Application + ApplicationVersion records + are byte-equal (modulo `uuid` and timestamps) to the declarative + path diff --git a/openspec/changes/openbuilt-versioning/specs/openbuilt-runtime/spec.md b/openspec/changes/openbuilt-versioning/specs/openbuilt-runtime/spec.md new file mode 100644 index 00000000..94a9ada3 --- /dev/null +++ b/openspec/changes/openbuilt-versioning/specs/openbuilt-runtime/spec.md @@ -0,0 +1,152 @@ +## ADDED Requirements + +### Requirement: REQ-OBR-006 Application editor exposes a Publish action + +`ApplicationEditor.vue` (REQ-OBR-005) SHALL render a "Publish" +action button alongside the existing Save action. Clicking Publish +SHALL: (a) require the textarea manifest to validate cleanly via +`validateManifest`; (b) on validation success, PUT any pending +manifest changes to OR and then call the Application's +`draft → published` lifecycle transition endpoint; (c) on +transition success, surface a confirmation toast naming the new +`ApplicationVersion` `uuid` returned in the response; (d) on +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. + +#### Scenario: Successful publish creates a snapshot + +- **WHEN** an integrator opens the editor for a draft Application, + edits the manifest validly, and clicks Publish +- **THEN** the manifest is saved to OR +- **AND** the lifecycle transition is invoked +- **AND** the confirmation toast surfaces with the newly created + `ApplicationVersion` `uuid` + +#### Scenario: Validation blocks publish + +- **WHEN** an integrator clicks Publish while the manifest is + invalid +- **THEN** no save or lifecycle call is sent +- **AND** the editor surfaces the validation error inline (same + contract as Save) + +### Requirement: REQ-OBR-007 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 +places: (1) each row of the Application list view carries a small +status badge (`draft` / `published` / `archived`); (2) the editor +header for an open Application carries the same badge plus a +"draft modified since last publish" indicator when the in-textarea +manifest differs from the most recent `ApplicationVersion.manifest`. +The badge SHALL use Nextcloud CSS variables for colour (no +hardcoded colour literals — per ADR-010). + +#### Scenario: Newly published Application shows published badge + +- **WHEN** an Application has been published and its draft has not + yet been modified +- **THEN** both the list row and the editor header show a + `published` badge +- **AND** no "draft modified since last publish" indicator is + shown + +#### Scenario: Edited draft shows modified indicator + +- **WHEN** an integrator has edited an Application's manifest after + the most recent publish but before publishing again +- **THEN** the editor header shows the `draft` badge with a + "modified since last publish" marker +- **AND** the list row reflects the same state + +### Requirement: REQ-OBR-008 VersionHistory.vue lists snapshots for an Application + +The OpenBuilt shell SHALL render a `VersionHistory.vue` panel +inside `ApplicationEditor.vue` (collapsible / a sibling tab, +implementer's choice) listing every `ApplicationVersion` row for +the current Application in reverse-chronological order (newest +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. + +#### Scenario: History panel renders snapshots + +- **WHEN** an integrator opens an Application that has three + `ApplicationVersion` rows +- **THEN** the version-history panel renders three rows in + newest-first order +- **AND** each row shows `version`, `publishedAt`, `publishedBy`, + and `notes` + +#### Scenario: History panel is empty for a never-published Application + +- **WHEN** an integrator opens a `draft` Application that has no + `ApplicationVersion` rows yet +- **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 + +Each row in the `VersionHistory.vue` panel SHALL carry a "Roll back +to this version" action. Clicking it SHALL: (a) prompt for +confirmation in a modal naming the target `version`; (b) on +confirmation, PUT the chosen snapshot's `manifest` onto the +Application as the new draft manifest, set the Application's +`version` per REQ-OBV-003, and leave the Application's `status` as +`draft`; (c) refresh the editor so the textarea reflects the +restored manifest. Per design.md Decision 3 the rollback is +audit-clean — it does **not** delete or mutate existing +`ApplicationVersion` rows. The confirmation modal SHALL live in +its own SFC under `src/modals/` per Hydra modal-isolation gate +(ADR-004). + +#### Scenario: Rollback restores manifest and stays in draft + +- **WHEN** an integrator clicks "Roll back to this version" on the + oldest row in the history panel and confirms in the modal +- **THEN** the Application's draft manifest is byte-equal to the + chosen snapshot's manifest +- **AND** the Application's status is `draft` +- **AND** no `ApplicationVersion` row has been deleted + +#### Scenario: Cancelling the confirmation aborts the rollback + +- **WHEN** the integrator opens the confirmation modal and clicks + Cancel +- **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 + +The OpenBuilt shell SHALL ship a `ManifestDiff.vue` component +rendering a client-side side-by-side diff between two manifest +blobs. The component SHALL: (a) accept `from` and `to` +`ApplicationVersion` UUIDs (or the literal `draft` for either) as +props; (b) fetch both manifests via the diff endpoint defined in +REQ-OBV-005; (c) compute the diff client-side via `jsdiff` (or an +equivalent library — per design.md Decision 5); (d) render added +lines, removed lines, and unchanged lines with NL Design +colour-coded tokens using Nextcloud CSS variables. By default the +editor SHALL preselect `from=draft` and `to=` when +the diff view is opened. + +#### Scenario: Default diff shows current draft vs latest published + +- **WHEN** an integrator opens the diff view from the editor of an + Application that has been published at least once +- **THEN** the component fetches the diff endpoint with + `from=draft` and `to=` +- **AND** the side-by-side rendering shows the diff between the + two manifests +- **AND** the diff is computed client-side (no second round-trip + to a server-side diff service) + +#### Scenario: Arbitrary snapshot pair can be diffed + +- **WHEN** an integrator selects two arbitrary + `ApplicationVersion` rows from the version-history panel and + invokes "Compare" +- **THEN** `ManifestDiff.vue` mounts with those two UUIDs +- **AND** the rendered diff matches what the diff endpoint + returned for that pair diff --git a/openspec/changes/openbuilt-versioning/specs/openbuilt-version-snapshots/spec.md b/openspec/changes/openbuilt-versioning/specs/openbuilt-version-snapshots/spec.md new file mode 100644 index 00000000..a8773d53 --- /dev/null +++ b/openspec/changes/openbuilt-versioning/specs/openbuilt-version-snapshots/spec.md @@ -0,0 +1,187 @@ +## ADDED Requirements + +### Requirement: REQ-OBV-001 ApplicationVersion schema declared in OpenRegister + +The system SHALL declare an `ApplicationVersion` schema in +`lib/Settings/openbuilt_register.json` under the `openbuilt` register +namespace. The schema SHALL define properties `uuid` (string, +UUID-format), `applicationUuid` (string, UUID-format, required — +foreign reference to the parent Application), `version` (string, +semver pattern, required), `manifest` (object, required — full +manifest blob copy at snapshot time), `publishedAt` (string, ISO +8601 date-time, required), `publishedBy` (string, NC user id, +required), and `notes` (string, optional). The schema SHALL be +imported into OpenRegister at app install / post-migration time via +the existing repair step. No PHP service class (e.g. +`ApplicationVersionService`) SHALL be written to wrap CRUD over this +schema — clients read and write `ApplicationVersion` rows via OR's +REST API per ADR-022. + +#### Scenario: Schema is available after install + +- **WHEN** the OpenBuilt app's repair step runs against an install + that already has the `Application` schema from spec #1 +- **THEN** OpenRegister exposes the `openbuilt/application-version` + schema with the declared properties +- **AND** the schema appears in OR's standard schema listing for the + `openbuilt` register namespace + +#### Scenario: ApplicationVersion row is created via OR REST + +- **WHEN** a client POSTs a valid payload (carrying + `applicationUuid`, `version`, `manifest`, `publishedAt`, + `publishedBy`) to OR's REST endpoint for the + `openbuilt/application-version` namespace +- **THEN** OR persists the object, returns 201, and the returned + object carries an OR-assigned `uuid` and the submitted fields + +### Requirement: REQ-OBV-002 Snapshot is created on draft-to-published transition + +The system SHALL create an `ApplicationVersion` row every time an +`Application` transitions from `draft` to `published`. The snapshot +SHALL deep-copy the Application's current `manifest` blob, capture +its current `version` string, set `publishedAt` to the transition's +server timestamp, set `publishedBy` to the authenticated actor's NC +user id, and set `applicationUuid` to the Application's UUID. The +snapshot creation SHALL be declared as +`x-openregister-lifecycle.on_transition` action metadata on the +`Application` schema. If OR's lifecycle engine does not yet expose +an action that can create a sibling object on a state transition, +the system MAY ship a single +`ApplicationVersionSnapshotListener` PHP class subscribed to OR's +`ObjectLifecycleTransitionedEvent` as an ADR-031 §Exceptions(1) +fallback — the behaviour observed from outside SHALL be identical +in either case. + +#### Scenario: Publishing creates a snapshot + +- **WHEN** an authenticated user transitions a `draft` Application + to `published` via the lifecycle endpoint +- **THEN** a new `ApplicationVersion` row exists in OR with + `applicationUuid` matching the Application's UUID, `manifest` + byte-equal to the Application's manifest at transition time, + `version` matching the Application's `version`, and `publishedAt` + / `publishedBy` populated + +#### Scenario: Failed transition creates no snapshot + +- **WHEN** an authenticated user attempts a disallowed transition + (e.g. `draft → archived` per spec #1's lifecycle declaration) +- **THEN** no `ApplicationVersion` row is created +- **AND** no audit `lifecycle.transition` entry is recorded + +### Requirement: REQ-OBV-003 Rollback restores a previous snapshot as the draft manifest + +The system SHALL support rolling back an Application to any of its +historical `ApplicationVersion` snapshots. The rollback action +SHALL copy the chosen `ApplicationVersion.manifest` blob onto the +Application's current draft manifest (leaving the Application in +`draft` status, ready for republish), set the Application's +`version` to the chosen snapshot's `version` suffixed with a +rollback marker (e.g. `+rollback`) so that the next publish creates +a new distinct snapshot, and SHALL **not** delete or overwrite any +existing `ApplicationVersion` row — history is append-only. + +#### Scenario: Rollback restores the manifest without history rewrite + +- **WHEN** an Application has three historical `ApplicationVersion` + rows (v1.0.0, v1.1.0, v1.2.0) +- **AND** an authenticated user rolls back to v1.0.0 +- **THEN** the Application's draft `manifest` is byte-equal to + v1.0.0's `manifest` +- **AND** all three original `ApplicationVersion` rows remain + unchanged in OR +- **AND** the Application's `status` is `draft` + +#### Scenario: Republish after rollback creates a fresh snapshot + +- **WHEN** the user from the previous scenario publishes the + rolled-back draft +- **THEN** a fourth `ApplicationVersion` row is created with a new + `uuid`, the restored manifest, and a fresh `publishedAt` +- **AND** the Application's `currentVersion` points at the new row, + not at v1.0.0 + +### Requirement: REQ-OBV-004 Version history is retained without retention cap + +The system SHALL retain every `ApplicationVersion` row indefinitely +for the foreseeable future of an Application's lifetime. No +automatic deletion, time-based expiry, or "keep last N" trimming +SHALL be applied in v1. Retention policy is explicitly deferred — +storage cost is bounded by manifest blob size (kilobytes per snapshot) +and the expected publish cadence (a handful per day per app at most). +If a future spec introduces a retention cap, it SHALL be opt-in +per Application. + +#### Scenario: Old snapshots remain queryable + +- **WHEN** an Application has been publishing for an extended period + and accumulated many `ApplicationVersion` rows +- **THEN** the oldest row remains readable via OR REST +- **AND** no row has been deleted by automatic retention logic + +### Requirement: REQ-OBV-005 Diff endpoint returns two manifest blobs in one call + +The system SHALL expose +`GET /index.php/apps/openbuilt/api/applications/{slug}/versions/diff?from={uuidA}&to={uuidB}` +backed by `ApplicationsController::diffVersions`. The endpoint +SHALL resolve `{slug}` to an Application via the `BuiltAppRoute` +index (spec #1), look up both referenced `ApplicationVersion` rows +(or accept the literal string `draft` for either parameter to mean +"the current draft manifest on the Application"), and return a JSON +body of shape `{ from: { manifest, version, publishedAt }, to: { +manifest, version, publishedAt } }` so that the client diff +component renders without a second round-trip. The endpoint SHALL +respond `200` on success, `404` if any referenced version row is +missing, and SHALL enforce the same organisation scoping as the +manifest endpoint (spec #1). The endpoint SHALL carry +`#[NoAdminRequired]` and SHALL be registered in +`appinfo/routes.php`. + +#### Scenario: Diff endpoint returns both manifests + +- **WHEN** an authenticated user GETs the diff endpoint with two + valid `ApplicationVersion` UUIDs for the same Application +- **THEN** the response is `200 application/json` +- **AND** the body contains both manifests unwrapped under `from` + and `to` + +#### Scenario: Diff against current draft + +- **WHEN** an authenticated user GETs the diff endpoint with + `from=draft` and `to=` +- **THEN** `from.manifest` is the Application's current draft + manifest and `to.manifest` is the published snapshot's manifest + +#### Scenario: Missing version returns 404 + +- **WHEN** an authenticated user GETs the diff endpoint with a + `from` UUID that has no matching `ApplicationVersion` +- **THEN** the response is `404` with a JSON error body +- **AND** no partial data is leaked + +### Requirement: REQ-OBV-006 Current version reference is maintained on the Application + +The `Application` schema (spec #1) SHALL be extended with a +`currentVersion` property (string, UUID-format, optional). The +declarative lifecycle action that creates the snapshot SHALL also +update the Application's `currentVersion` to point at the freshly +created `ApplicationVersion` row's `uuid`. The same listener +fallback path described in REQ-OBV-002 SHALL be used if the +declarative path is unavailable. Reading `Application.currentVersion` +SHALL be the canonical way to identify "the latest published +manifest" without scanning the `ApplicationVersion` collection. + +#### Scenario: First publish populates currentVersion + +- **WHEN** an Application that has never been published transitions + from `draft` to `published` +- **THEN** the Application's `currentVersion` is set to the UUID of + the newly created `ApplicationVersion` row + +#### Scenario: Re-publish updates currentVersion + +- **WHEN** an Application is published a second time +- **THEN** its `currentVersion` is updated to the UUID of the + second `ApplicationVersion` row +- **AND** the first row remains intact and discoverable via OR REST diff --git a/openspec/changes/openbuilt-versioning/tasks.md b/openspec/changes/openbuilt-versioning/tasks.md new file mode 100644 index 00000000..0fcf56e7 --- /dev/null +++ b/openspec/changes/openbuilt-versioning/tasks.md @@ -0,0 +1,130 @@ +## 1. Implementation Tasks — openbuilt-version-snapshots + +- [ ] 1.1 **Declare `ApplicationVersion` schema in `lib/Settings/openbuilt_register.json`** + - spec_ref: REQ-OBV-001 + - files: `lib/Settings/openbuilt_register.json` + - acceptance_criteria: Schema declares `uuid` (UUID-format), `applicationUuid` (UUID-format, required), `version` (semver pattern, required), `manifest` (object, required — references the canonical app-manifest schema), `publishedAt` (ISO 8601 date-time, required), `publishedBy` (string, required), `notes` (string, optional). Validates against OpenAPI 3.0.0. Lives in the `openbuilt` register namespace alongside `Application` and `BuiltAppRoute`. + - Implement: declarative — no PHP service class. + - Test: integration test creates an `ApplicationVersion` row via OR REST, asserts validation rejects a payload missing `applicationUuid`. + +- [ ] 1.2 **Extend the `Application` schema with `currentVersion`** + - spec_ref: REQ-OBA-006, REQ-OBV-006 + - files: `lib/Settings/openbuilt_register.json` + - acceptance_criteria: `Application` schema gains a `currentVersion` property (string, UUID-format, optional). Existing seeded Applications from spec #1 remain valid (no required-field upgrade). + - Implement: declarative schema patch — no PHP migration code. + - Test: integration test loads the seeded `hello-world` Application post-upgrade and asserts it still parses; asserts `currentVersion` field is present and absent / null. + +- [ ] 1.3 **Declare the snapshot-on-publish action on the Application schema (declarative path)** + - spec_ref: REQ-OBV-002, REQ-OBA-007 + - files: `lib/Settings/openbuilt_register.json` (NOT a new PHP service class) + - acceptance_criteria: `x-openregister-lifecycle.on_transition` action on the `draft → published` edge declares: (a) create a sibling `ApplicationVersion` record copying `manifest`, `version`, and metadata; (b) update Application's `currentVersion`; (c) reset Application's `status` to `draft` per design.md Decision 3. + - Implement: declarative-first per ADR-031. NO `VersioningService` / `SnapshotService` / `ApplicationVersionManager` class. + - Test: integration test publishes a draft Application, asserts a fresh `ApplicationVersion` row exists with byte-equal manifest and correct metadata, asserts `currentVersion` points at it, asserts Application status is `draft` post-action. + +- [ ] 1.4 **Fall back to a single listener PHP class IF the declarative path is unavailable (ADR-031 §Exceptions(1))** + - spec_ref: REQ-OBV-002, REQ-OBA-007 + - files: `lib/Listener/ApplicationVersionSnapshotListener.php` (ONLY if task 1.3's declarative path proves engine-unsupported) + - acceptance_criteria: Single listener subscribed to OR's `ObjectLifecycleTransitionedEvent`; same observable behaviour as task 1.3; carries SPDX + EUPL-1.2 docblock; no broader business logic. Recorded in `hydra.json` under `decisions[]` with rationale. OR-side issue filed referencing bootstrap-openbuilt's OQ-1. + - Implement: only if necessary; thin-glue exception per ADR-032. + - Test: integration test parity with task 1.3 — same publish flow, same assertions, listener path is exercised. + +- [ ] 1.5 **Register the diff endpoint route in `appinfo/routes.php`** + - spec_ref: REQ-OBV-005 + - files: `appinfo/routes.php` + - acceptance_criteria: Route `GET /api/applications/{slug}/versions/diff` maps to `applications#diffVersions` with `#[NoAdminRequired]`. Registration only in `routes.php` (per ADR-016). + - Implement: ~3 LOC route declaration. + - Test: Newman request resolves the route to the controller. + +- [ ] 1.6 **Add `ApplicationsController::diffVersions` (thin-glue per ADR-032)** + - spec_ref: REQ-OBV-005 + - files: `lib/Controller/ApplicationsController.php` + - acceptance_criteria: `diffVersions(string $slug, string $from, string $to): JSONResponse` resolves slug → Application via `BuiltAppRoute`, accepts `draft` as a literal for either parameter (returns the Application's current draft manifest), looks up both `ApplicationVersion` rows via OR's ObjectService, returns `{ from: { manifest, version, publishedAt }, to: { manifest, version, publishedAt } }`, responds 200 on hit / 404 on miss. ~25 LOC. SPDX + EUPL-1.2 docblock per memory rule. `#[NoAdminRequired]` set so route-auth gate-5 passes. + - Implement: single method, no service class. + - Test: PHPUnit asserts 200 with both blobs for valid UUIDs, 404 for unknown UUID, organisation-scope enforcement. + +## 2. Implementation Tasks — openbuilt-application-register (modifications) + +- [ ] 2.1 **Confirm `currentVersion` is wired by the same lifecycle action as the snapshot** + - spec_ref: REQ-OBA-006, REQ-OBA-007 + - files: `lib/Settings/openbuilt_register.json` (re-uses the action declared in task 1.3) + - acceptance_criteria: The single `on_transition` action (or its listener fallback) writes BOTH the new `ApplicationVersion` row AND the Application's `currentVersion` in one logical step. No separate hook / action / listener for `currentVersion` upkeep. + - Implement: declarative — covered by tasks 1.3 / 1.4. + - Test: integration test publishes twice and asserts `currentVersion` updates to the second row's `uuid`; asserts the first row remains intact. + +## 3. Implementation Tasks — openbuilt-runtime (modifications) + +- [ ] 3.1 **Add Publish action and status badge to `ApplicationEditor.vue`** + - spec_ref: REQ-OBR-006, REQ-OBR-007 + - files: `src/views/ApplicationEditor.vue`, `src/store/applications.js` + - acceptance_criteria: Editor header carries a `status` badge (draft / published / archived) using NC CSS variables (no hardcoded colours per ADR-010); Publish button is disabled when manifest is invalid or while in-flight; clicking Publish saves the manifest then invokes the `draft → published` lifecycle endpoint; on success a toast names the newly created `ApplicationVersion.uuid`. "Draft modified since last publish" indicator surfaces when textarea diverges from `currentVersion.manifest`. + - Implement: Options API; reuse `createObjectStore` (memory rule); no custom Pinia layer. + - Test: Playwright pastes a valid manifest, clicks Publish, asserts the toast surfaces, asserts a new `ApplicationVersion` row is queryable via OR REST. + +- [ ] 3.2 **Build `VersionHistory.vue` panel** + - spec_ref: REQ-OBR-008 + - files: `src/views/VersionHistory.vue`, `src/router/index.js` (sibling tab registration) + - acceptance_criteria: Panel reads `ApplicationVersion` rows from OR REST filtered by `applicationUuid`, renders newest-first, shows `version`, localised `publishedAt`, `publishedBy`, and `notes`. Empty state for never-published Applications with no console errors. Mounted as a sibling tab inside `ApplicationEditor.vue` per design.md OQ-4 provisional decision. + - Implement: Options API; no app-local wrapper service. + - Test: Playwright opens an Application with three historical versions, asserts three rows render in newest-first order; opens an unpublished Application, asserts the empty state renders without console errors. + +- [ ] 3.3 **Build rollback action + `RollbackConfirmModal.vue` (modal-isolation per Hydra ADR-004 gate)** + - spec_ref: REQ-OBR-009, REQ-OBV-003 + - files: `src/modals/RollbackConfirmModal.vue` (own SFC per Hydra modal-isolation gate), `src/views/VersionHistory.vue` + - acceptance_criteria: Each history row carries "Roll back to this version" button; clicking opens the confirmation modal naming the target `version`; confirming PUTs the chosen snapshot's manifest onto the Application as the new draft, sets the rollback version marker (per design.md OQ-2 provisional `-rollback-<6hex>`), leaves status `draft`, refreshes the textarea. Modal lives in `src/modals/` (NOT inline in parent) per ADR-004 modal-isolation hard rule. Cancel aborts without writes. + - Implement: NcDialog or NcModal as appropriate; passes `hydra-gate-modal-isolation`. + - Test: Playwright clicks rollback, confirms, asserts textarea reflects the chosen snapshot's manifest, asserts all historical rows remain present. + +- [ ] 3.4 **Build `ManifestDiff.vue` component with client-side diff (per design.md Decision 5)** + - spec_ref: REQ-OBR-010 + - files: `src/components/ManifestDiff.vue`, `package.json` (add `jsdiff` or equivalent — pending audit per design.md OQ-5) + - acceptance_criteria: Accepts `from` and `to` props (`ApplicationVersion` UUIDs or the literal `draft`); fetches via the diff endpoint (REQ-OBV-005); JSON-pretty-prints both blobs deterministically (sorted keys, stable indent); runs `jsdiff` (or chosen library) and renders side-by-side with added/removed/unchanged tokens coloured via NC CSS variables. Default load: `from=draft`, `to=currentVersion`. Diff library audit recorded in design.md OQ-5 resolution and `hydra.json`. + - Implement: client-side only; no server-side diff service. + - Test: Playwright opens the diff view for an Application with a modified draft vs its published version, asserts the diff visualises the change; selects two arbitrary history rows and asserts a different diff renders. + +- [ ] 3.5 **Wire "Compare with current draft" affordance on history rows (per design.md OQ-4)** + - spec_ref: REQ-OBR-008, REQ-OBR-010 + - files: `src/views/VersionHistory.vue`, `src/views/ApplicationEditor.vue` + - acceptance_criteria: Each version-history row has a one-click action that switches to the diff sibling tab pre-loaded with `from=draft`, `to=`. No new route — uses the existing diff tab. + - Implement: emit an event from `VersionHistory.vue`, handle it in `ApplicationEditor.vue`. + - Test: Playwright clicks "Compare with current draft" on a history row, asserts the diff tab activates with the correct pair preloaded. + +## 4. Seed Data (ADR-001) + +- [ ] 4.1 **Extend `SeedHelloWorld.php` to seed one `ApplicationVersion` and set Application.currentVersion** + - spec_ref: design.md Seed Data section + - files: `lib/Repair/SeedHelloWorld.php` + - acceptance_criteria: Seeds one `ApplicationVersion` row for the seeded `hello-world` Application: `applicationUuid` = the Application's UUID, `version` = `1.0.0`, `manifest` = byte-equal copy, `publishedAt` = install timestamp, `publishedBy` = `system`, `notes` = "Seeded by OpenBuilt install — initial published version". Sets the Application's `currentVersion` to the new row's UUID. Idempotent: re-running the repair on a seeded install does NOT create a duplicate `ApplicationVersion`. No scripting — edit via PHP only (memory rule). + - Implement: extend the existing repair step; guard on existing-row check. + - Test: PHPUnit runs the repair step twice, asserts exactly one `ApplicationVersion` exists for `hello-world`; asserts `currentVersion` is set. + +## 5. Verification + +- [ ] 5.1 Run `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan) — all green. Fix any pre-existing issues in touched files (memory rule). +- [ ] 5.2 Run `npm run lint` / ESLint — clean on the new SFCs. +- [ ] 5.3 Run `npm run check:manifest` (ADR-024) on the seeded `hello-world` manifest after the snapshot — passes. +- [ ] 5.4 Confirm NO `VersioningService.php` / `SnapshotService.php` / `ApplicationVersionService.php` / `ManifestVersionManager.php` (or similarly-named class) exists under `lib/Service/` — ADR-031 review gate. +- [ ] 5.5 Run Hydra mechanical gates locally (`scripts/run-hydra-gates.sh`): SPDX headers, forbidden-patterns, stub-scan, composer-audit, route-auth, orphan-auth, no-admin-idor, unsafe-auth-resolver, semantic-auth, initial-state, admin-router, nc-input-labels, modal-isolation — all pass. Modal-isolation is the relevant new gate (RollbackConfirmModal in `src/modals/`). +- [ ] 5.6 Visually verify on a fresh `docker compose up` that the seeded `hello-world` Application shows one history row, the Publish button creates a second row, and the diff view renders cleanly between them. +- [ ] 5.7 Resolve design.md OQ-1 (declarative vs listener) — record path chosen in `hydra.json`. +- [ ] 5.8 Resolve design.md OQ-2 (rollback marker form) — record final form in code + `hydra.json`. +- [ ] 5.9 Resolve design.md OQ-5 (diff library choice) — record audit outcome in `hydra.json`; confirm not shadowed by `@conduction/nextcloud-vue`. + +## 6. Tests (ADR-008) + +- [ ] 6.1 **PHPUnit** — `tests/Unit/Controller/ApplicationsControllerTest.php::diffVersions` covers 200 + 404 + organisation scoping + `draft` literal handling for `from` / `to`. +- [ ] 6.2 **PHPUnit** — `tests/Integration/ApplicationVersioningTest.php` walks `draft → published → rollback → republish` and asserts: snapshot created on first publish, `currentVersion` updated, manifest preserved byte-equal, rollback restores manifest without history rewrite, republish creates a fresh row. +- [ ] 6.3 **Newman** — `tests/api/openbuilt.postman_collection.json` adds requests for the diff endpoint (200 + 404), reading `ApplicationVersion` rows by `applicationUuid` filter, and posting a manual `ApplicationVersion` (smoke test for the schema declaration). +- [ ] 6.4 **Playwright** — `tests/e2e/versioning.spec.ts` covers: navigate to a draft Application, edit and Publish, assert the new history row appears; rollback to the previous row, assert the textarea reflects the restored manifest; open the diff view and assert the visualisation renders without console errors. + +## 7. Documentation (ADR-009, ADR-010) + +- [ ] 7.1 Extend `docs/openbuilt-runtime.md` with a "Versioning" section covering the snapshot-on-publish flow, rollback semantics (audit-clean per Decision 3), and the diff view's UX contract. +- [ ] 7.2 Add a "How to safely iterate on a published app" walkthrough to `docs/integrator-guide.md` — covers draft / publish / rollback cycle. +- [ ] 7.3 NL Design (ADR-010) — confirm the new status badge, diff tokens, and version-history panel use Nextcloud CSS variables only; document any new variables added. +- [ ] 7.4 Update `openspec/app-config.json` to list `openbuilt-version-snapshots` under capabilities. + +## 8. i18n (ADR-005, ADR-007) + +- [ ] 8.1 Add English translations in `l10n/en.json` for new keys: `openbuilt.editor.publish`, `openbuilt.editor.publishing`, `openbuilt.editor.status.draft|published|archived`, `openbuilt.editor.draftModified`, `openbuilt.versionHistory.title`, `openbuilt.versionHistory.empty`, `openbuilt.versionHistory.rollback`, `openbuilt.versionHistory.compare`, `openbuilt.rollback.confirm.title`, `openbuilt.rollback.confirm.body`, `openbuilt.rollback.confirm.confirm`, `openbuilt.rollback.confirm.cancel`, `openbuilt.diff.title`, `openbuilt.diff.empty`. +- [ ] 8.2 Add Dutch translations for the same keys in `l10n/nl.json` (memory rule: Dutch + English minimum). +- [ ] 8.3 Confirm no hardcoded UI strings in the new SFCs — every label runs through `t('openbuilt', '')`. From a8ebd4582e289ce3d3d928284944a736d4420066 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 11 May 2026 12:34:19 +0200 Subject: [PATCH 2/8] =?UTF-8?q?Apply=20openbuilt-versioning=20=E2=80=94=20?= =?UTF-8?q?backend=20(schema,=20listener,=20diff=20endpoint,=20seed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ApplicationVersion schema to lib/Settings/openbuilt_register.json - Add currentVersion property to Application schema (optional UUID) - Declare on_transition.create_relation action on draft->published edge (decorative; ADR-031 §Exceptions(1) listener does the work — OQ-1) - New ApplicationVersionSnapshotListener subscribed to OR's ObjectTransitionedEvent: creates the snapshot, sets currentVersion, resets status to draft (design.md Decision 3 audit-clean rollback) - New ApplicationsController::diffVersions thin-glue endpoint (REQ-OBV-005) with draft-literal handling, organisation scoping, 200/404 contract - Route registered in appinfo/routes.php (specific-first per memory rule) - SeedHelloWorld extended to seed v1.0.0 ApplicationVersion + Application.currentVersion - Bumped seed version to 1.0.0 (was 0.1.0) to match spec scenario - i18n keys (en+nl): publish, status badges, version history, rollback, diff Tasks: 1.1, 1.2, 1.3 (decorative), 1.4 (listener — OQ-1 path), 1.5, 1.6, 2.1, 4.1, 8.1, 8.2 --- appinfo/routes.php | 6 + l10n/en.json | 30 ++- l10n/nl.json | 30 ++- lib/AppInfo/Application.php | 11 ++ lib/Controller/ApplicationsController.php | 145 +++++++++++++++ .../ApplicationVersionSnapshotListener.php | 176 ++++++++++++++++++ lib/Repair/SeedHelloWorld.php | 52 +++++- lib/Settings/openbuilt_register.json | 61 +++++- 8 files changed, 504 insertions(+), 7 deletions(-) create mode 100644 lib/Listener/ApplicationVersionSnapshotListener.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 917b7251..14a32751 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -23,6 +23,12 @@ // and BuiltAppRoute schemas (^[a-z0-9][a-z0-9-]*[a-z0-9]$, min 2 max 48 chars). ['name' => 'applications#getManifest', 'url' => '/api/applications/{slug}/manifest', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + // Versioning — diff endpoint (chain spec #6 openbuilt-versioning, REQ-OBV-005). Returns + // two ApplicationVersion manifest blobs in one round-trip so the client diff component + // does not double-fetch. `from`/`to` are ApplicationVersion UUIDs OR the literal `draft`. + // Specific route MUST precede the SPA catch-all (memory rule: Symfony specific-first). + ['name' => 'applications#diffVersions', 'url' => '/api/applications/{slug}/versions/diff', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + // SPA catch-all — same controller as the index route; must use a distinct route name // (duplicate names replace the earlier route in Symfony, which breaks GET /). ['name' => 'dashboard#catchAll', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => '']], diff --git a/l10n/en.json b/l10n/en.json index 3f703510..f4df5247 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -44,7 +44,35 @@ "openbuilt.helloworld.title.messages": "Hello World — messages", "openbuilt.helloworld.title.message": "Message", "openbuilt.helloworld.title.create": "New message", - "openbuilt.editor.help": "Integrator-only editor: edit the raw JSON manifest. Visual editor lives in chain spec openbuilt-page-editor." + "openbuilt.editor.help": "Integrator-only editor: edit the raw JSON manifest. Visual editor lives in chain spec openbuilt-page-editor.", + + "openbuilt.editor.publish": "Publish", + "openbuilt.editor.publishing": "Publishing…", + "openbuilt.editor.status.draft": "draft", + "openbuilt.editor.status.published": "published", + "openbuilt.editor.status.archived": "archived", + "openbuilt.editor.draftModified": "modified since last publish", + "openbuilt.editor.tab.editor": "Editor", + "openbuilt.editor.tab.history": "Version history", + "openbuilt.editor.tab.diff": "Diff", + "openbuilt.editor.publishedToast": "Published version {uuid}", + "openbuilt.versionHistory.title": "Version history", + "openbuilt.versionHistory.empty": "No versions yet — publish this app to create the first snapshot.", + "openbuilt.versionHistory.rollback": "Roll back to this version", + "openbuilt.versionHistory.compare": "Compare with current draft", + "openbuilt.versionHistory.publishedAt": "Published", + "openbuilt.versionHistory.publishedBy": "By", + "openbuilt.versionHistory.notes": "Notes", + "openbuilt.rollback.confirm.title": "Roll back to version {version}?", + "openbuilt.rollback.confirm.body": "Rolling back copies this snapshot's manifest onto the current draft. Existing history is preserved (append-only).", + "openbuilt.rollback.confirm.confirm": "Roll back", + "openbuilt.rollback.confirm.cancel": "Cancel", + "openbuilt.diff.title": "Manifest diff", + "openbuilt.diff.empty": "Nothing to diff — publish the app first.", + "openbuilt.diff.from": "From", + "openbuilt.diff.to": "To", + "openbuilt.diff.draft": "Current draft", + "openbuilt.diff.loading": "Loading diff…" }, "plurals": "" } diff --git a/l10n/nl.json b/l10n/nl.json index 6eabd55a..3d0ffa3b 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -44,7 +44,35 @@ "openbuilt.helloworld.title.messages": "Hello World — berichten", "openbuilt.helloworld.title.message": "Bericht", "openbuilt.helloworld.title.create": "Nieuw bericht", - "openbuilt.editor.help": "Editor voor integrators: bewerk het ruwe JSON-manifest. De visuele editor komt in vervolgspec openbuilt-page-editor." + "openbuilt.editor.help": "Editor voor integrators: bewerk het ruwe JSON-manifest. De visuele editor komt in vervolgspec openbuilt-page-editor.", + + "openbuilt.editor.publish": "Publiceren", + "openbuilt.editor.publishing": "Publiceren…", + "openbuilt.editor.status.draft": "concept", + "openbuilt.editor.status.published": "gepubliceerd", + "openbuilt.editor.status.archived": "gearchiveerd", + "openbuilt.editor.draftModified": "gewijzigd sinds laatste publicatie", + "openbuilt.editor.tab.editor": "Editor", + "openbuilt.editor.tab.history": "Versiegeschiedenis", + "openbuilt.editor.tab.diff": "Vergelijken", + "openbuilt.editor.publishedToast": "Versie {uuid} gepubliceerd", + "openbuilt.versionHistory.title": "Versiegeschiedenis", + "openbuilt.versionHistory.empty": "Nog geen versies — publiceer deze app om de eerste snapshot te maken.", + "openbuilt.versionHistory.rollback": "Terugzetten naar deze versie", + "openbuilt.versionHistory.compare": "Vergelijken met huidig concept", + "openbuilt.versionHistory.publishedAt": "Gepubliceerd", + "openbuilt.versionHistory.publishedBy": "Door", + "openbuilt.versionHistory.notes": "Notities", + "openbuilt.rollback.confirm.title": "Terugzetten naar versie {version}?", + "openbuilt.rollback.confirm.body": "Bij terugzetten wordt het manifest van deze snapshot op het huidige concept geplaatst. Bestaande geschiedenis blijft behouden (alleen toevoegen).", + "openbuilt.rollback.confirm.confirm": "Terugzetten", + "openbuilt.rollback.confirm.cancel": "Annuleren", + "openbuilt.diff.title": "Manifest-vergelijking", + "openbuilt.diff.empty": "Niets te vergelijken — publiceer de app eerst.", + "openbuilt.diff.from": "Van", + "openbuilt.diff.to": "Naar", + "openbuilt.diff.draft": "Huidig concept", + "openbuilt.diff.loading": "Vergelijking laden…" }, "plurals": "" } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 33008895..83bfedab 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -24,8 +24,10 @@ namespace OCA\OpenBuilt\AppInfo; +use OCA\OpenBuilt\Listener\ApplicationVersionSnapshotListener; use OCA\OpenBuilt\Listener\DeepLinkRegistrationListener; use OCA\OpenRegister\Event\DeepLinkRegistrationEvent; +use OCA\OpenRegister\Event\ObjectTransitionedEvent; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -66,6 +68,15 @@ public function register(IRegistrationContext $context): void listener: DeepLinkRegistrationListener::class ); + // Snapshot the Application's manifest into ApplicationVersion on + // draft→published transitions (chain spec #6 openbuilt-versioning, + // ADR-031 §Exceptions(1) — declarative-first fallback because OR's + // engine does not yet execute on_transition.create_relation). + $context->registerEventListener( + event: ObjectTransitionedEvent::class, + listener: ApplicationVersionSnapshotListener::class + ); + // Repair steps (InitializeSettings + SeedHelloWorld) are declared in info.xml. }//end register() diff --git a/lib/Controller/ApplicationsController.php b/lib/Controller/ApplicationsController.php index 35a86ca4..47bff42e 100644 --- a/lib/Controller/ApplicationsController.php +++ b/lib/Controller/ApplicationsController.php @@ -189,6 +189,151 @@ public function getManifest(string $slug): JSONResponse }//end try }//end getManifest() + /** + * Return two manifest blobs side-by-side so the client diff component + * can render without a second round-trip (REQ-OBV-005, chain spec #6). + * + * Resolves `{slug}` to an Application via the BuiltAppRoute index, + * accepts the literal string `draft` for either `from`/`to` to mean + * "the current draft manifest on the Application", otherwise looks + * up both referenced ApplicationVersion rows. Returns a shape of + * `{ from: { manifest, version, publishedAt }, to: { manifest, + * version, publishedAt } }`. Per ADR-032 this is thin glue + * (~30 LOC of logic); no service class. + * + * @param string $slug The virtual-app slug from the URL + * @param string $from ApplicationVersion UUID or the literal `draft` + * @param string $to ApplicationVersion UUID or the literal `draft` + * + * @return JSONResponse Both blobs on 200, or a 404 envelope on miss + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function diffVersions(string $slug, string $from, string $to): JSONResponse + { + try { + $registerId = $this->registerMapper->find('openbuilt', _multitenancy: false)->getId(); + $routeSchema = $this->schemaMapper->find('built-app-route', _multitenancy: false)->getId(); + + $routeResults = $this->objectService->searchObjects( + query: [ + '@self' => [ + 'register' => $registerId, + 'schema' => $routeSchema, + ], + 'slug' => $slug, + ] + ); + + if (empty($routeResults) === true) { + return new JSONResponse( + data: ['error' => 'not_found', 'message' => 'No published virtual app found for slug '.$slug], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + $route = $this->normaliseObject(object: $routeResults[0]); + $applicationUuid = ($route['applicationUuid'] ?? null); + + if ($applicationUuid === null) { + return new JSONResponse( + data: ['error' => 'inconsistent_state', 'message' => 'Route exists but has no applicationUuid'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + + $application = $this->objectService->find( + id: $applicationUuid, + register: 'openbuilt', + schema: 'application' + ); + + if ($application === null) { + return new JSONResponse( + data: ['error' => 'not_found', 'message' => 'Application not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + $applicationArray = $this->normaliseObject(object: $application); + + $fromBlob = $this->resolveVersionBlob(token: $from, application: $applicationArray, applicationUuid: $applicationUuid); + if ($fromBlob === null) { + return new JSONResponse( + data: ['error' => 'not_found', 'message' => 'from version not found: '.$from], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + $toBlob = $this->resolveVersionBlob(token: $to, application: $applicationArray, applicationUuid: $applicationUuid); + if ($toBlob === null) { + return new JSONResponse( + data: ['error' => 'not_found', 'message' => 'to version not found: '.$to], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + return new JSONResponse( + data: ['from' => $fromBlob, 'to' => $toBlob], + statusCode: Http::STATUS_OK + ); + } catch (\Throwable $e) { + $this->logger->error('OpenBuilt: diffVersions failed for slug '.$slug.': '.$e->getMessage(), ['exception' => $e]); + return new JSONResponse( + data: ['error' => 'internal_error', 'message' => 'Failed to resolve diff'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end diffVersions() + + /** + * Resolve a `from`/`to` token to a `{ manifest, version, publishedAt }` blob. + * + * The literal string `draft` returns the Application's current draft + * fields. Any other value is treated as an ApplicationVersion UUID + * and looked up via OR's ObjectService. Returns null on miss so the + * caller can surface 404. + * + * @param string $token Token (`draft` or UUID). + * @param array $application Normalised Application data. + * @param string $applicationUuid Parent Application UUID for scoping. + * + * @return array|null Blob or null if the version is missing. + */ + private function resolveVersionBlob(string $token, array $application, string $applicationUuid): ?array + { + if ($token === 'draft') { + return [ + 'manifest' => ($application['manifest'] ?? null), + 'version' => ($application['version'] ?? null), + 'publishedAt' => null, + ]; + } + + $version = $this->objectService->find( + id: $token, + register: 'openbuilt', + schema: 'application-version' + ); + + if ($version === null) { + return null; + } + + $versionArray = $this->normaliseObject(object: $version); + + // Organisation-scope enforcement: a snapshot from another Application is a miss. + if (($versionArray['applicationUuid'] ?? null) !== $applicationUuid) { + return null; + } + + return [ + 'manifest' => ($versionArray['manifest'] ?? null), + 'version' => ($versionArray['version'] ?? null), + 'publishedAt' => ($versionArray['publishedAt'] ?? null), + ]; + }//end resolveVersionBlob() + /** * Coerce an OR result entry (ObjectEntity or array) to a plain associative array. * diff --git a/lib/Listener/ApplicationVersionSnapshotListener.php b/lib/Listener/ApplicationVersionSnapshotListener.php new file mode 100644 index 00000000..ab1c77a5 --- /dev/null +++ b/lib/Listener/ApplicationVersionSnapshotListener.php @@ -0,0 +1,176 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Listener; + +use OCA\OpenRegister\Event\ObjectTransitionedEvent; +use OCA\OpenRegister\Service\ObjectService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +/** + * Snapshots an Application's manifest into ApplicationVersion on publish. + * + * @template-implements IEventListener + */ +class ApplicationVersionSnapshotListener implements IEventListener +{ + private const REGISTER_SLUG = 'openbuilt'; + private const APPLICATION_SCHEMA = 'application'; + private const VERSION_SCHEMA = 'application-version'; + private const PUBLISH_FROM = 'draft'; + private const PUBLISH_TO = 'published'; + private const DEFAULT_PUBLISHED_BY = 'system'; + private const DEFAULT_SNAPSHOT_NOTES = 'Published via lifecycle transition'; + + /** + * Constructor. + * + * @param LoggerInterface $logger PSR logger for diagnostics + * @param ObjectService $objectService OpenRegister object service (hard dep via info.xml) + * + * @return void + */ + public function __construct( + private readonly LoggerInterface $logger, + private readonly ObjectService $objectService, + ) { + }//end __construct() + + /** + * Handle the ObjectTransitionedEvent. + * + * Filters on Application + draft→published, then creates the + * ApplicationVersion snapshot and writes back currentVersion + + * status reset on the Application. Failures are logged but + * never thrown — a snapshot failure must not block the + * underlying publish transition (it already succeeded by the + * time this listener runs). + * + * @param Event $event Dispatched event + * + * @return void + */ + public function handle(Event $event): void + { + if ($event instanceof ObjectTransitionedEvent === false) { + return; + } + + if ($event->getSchema() !== self::APPLICATION_SCHEMA) { + return; + } + + if ($event->getFrom() !== self::PUBLISH_FROM || $event->getTo() !== self::PUBLISH_TO) { + return; + } + + try { + $applicationData = $event->getObject()->jsonSerialize(); + $applicationSelf = (is_array($applicationData) === true ? ($applicationData['@self'] ?? []) : []); + $applicationUuid = ($applicationSelf['id'] ?? ($applicationSelf['uuid'] ?? ($applicationData['uuid'] ?? null))); + + if ($applicationUuid === null) { + $this->logger->warning('OpenBuilt: ApplicationVersionSnapshotListener could not resolve Application UUID; skipping snapshot.'); + return; + } + + $manifest = ($applicationData['manifest'] ?? []); + $version = ($applicationData['version'] ?? '0.0.0'); + $userId = ($event->getUserId() ?? self::DEFAULT_PUBLISHED_BY); + + // Create the ApplicationVersion sibling row. OR's standard scoping + // (organisation + register + schema) applies on saveObject so the + // snapshot inherits the Application's org context. + $snapshot = $this->objectService->saveObject( + object: [ + 'applicationUuid' => $applicationUuid, + 'version' => $version, + 'manifest' => $manifest, + 'publishedAt' => gmdate('Y-m-d\TH:i:s\Z'), + 'publishedBy' => $userId, + 'notes' => self::DEFAULT_SNAPSHOT_NOTES, + ], + register: self::REGISTER_SLUG, + schema: self::VERSION_SCHEMA + ); + + $snapshotData = (method_exists($snapshot, 'jsonSerialize') === true ? $snapshot->jsonSerialize() : []); + $snapshotSelf = (is_array($snapshotData) === true ? ($snapshotData['@self'] ?? []) : []); + $snapshotUuid = ($snapshotSelf['id'] ?? ($snapshotSelf['uuid'] ?? ($snapshotData['uuid'] ?? null))); + + if ($snapshotUuid === null) { + $this->logger->warning('OpenBuilt: ApplicationVersionSnapshotListener created a snapshot but could not read its UUID; currentVersion not updated.'); + return; + } + + // Writeback — update Application.currentVersion AND reset status + // back to draft (design.md Decision 3: persistent status is `draft` + // when editable; "is published right now" is `currentVersion != null`). + $existing = (is_array($applicationData) === true ? $applicationData : []); + unset($existing['@self']); + $existing['currentVersion'] = $snapshotUuid; + $existing['status'] = self::PUBLISH_FROM; + + $this->objectService->saveObject( + object: $existing, + register: self::REGISTER_SLUG, + schema: self::APPLICATION_SCHEMA + ); + + $this->logger->info( + 'OpenBuilt: snapshotted Application '.$applicationUuid.' as ApplicationVersion '.$snapshotUuid.' (version '.$version.').' + ); + } catch (\Throwable $e) { + // Never bubble up — the publish itself already succeeded; a + // failed snapshot must not roll the transition back. + $this->logger->error( + 'OpenBuilt: ApplicationVersionSnapshotListener failed: '.$e->getMessage(), + ['exception' => $e] + ); + }//end try + }//end handle() +}//end class diff --git a/lib/Repair/SeedHelloWorld.php b/lib/Repair/SeedHelloWorld.php index 42c6d7c2..4d86b92c 100644 --- a/lib/Repair/SeedHelloWorld.php +++ b/lib/Repair/SeedHelloWorld.php @@ -42,7 +42,8 @@ */ class SeedHelloWorld implements IRepairStep { - private const SEED_SLUG = 'hello-world'; + private const SEED_SLUG = 'hello-world'; + private const SEED_VERSION = '1.0.0'; /** * Constructor. @@ -102,14 +103,15 @@ public function run(IOutput $output): void // not yet support `on_transition.upsert_relation` as a declarative action // that creates a sibling object. Until OR ships that hook we explicitly // create the BuiltAppRoute here. This is the ADR-031 §Exceptions(1) path. - $application = $this->objectService->saveObject( + $seedManifest = $this->buildHelloWorldManifest(); + $application = $this->objectService->saveObject( object: [ 'slug' => self::SEED_SLUG, 'name' => 'Hello World', 'description' => 'The canonical seed virtual app for OpenBuilt. Exercises index + detail + form page types.', - 'version' => '0.1.0', + 'version' => self::SEED_VERSION, 'status' => 'published', - 'manifest' => $this->buildHelloWorldManifest(), + 'manifest' => $seedManifest, ], register: 'openbuilt', schema: 'application' @@ -136,6 +138,48 @@ public function run(IOutput $output): void schema: 'built-app-route' ); $output->info('Created BuiltAppRoute for hello-world.'); + + // Seed one ApplicationVersion snapshot (chain spec #6 openbuilt-versioning) + // so the version-history panel is non-empty on a fresh install and the + // diff view has something to render in the walkthrough. Same + // ADR-031 §Exceptions(1) rationale as the BuiltAppRoute upkeep above — + // the lifecycle action declared on Application is not yet executed by + // OR's engine, so we wire the seed snapshot explicitly. The listener + // does the same on every subsequent publish. + $snapshot = $this->objectService->saveObject( + object: [ + 'applicationUuid' => $applicationUuid, + 'version' => self::SEED_VERSION, + 'manifest' => $seedManifest, + 'publishedAt' => gmdate('Y-m-d\TH:i:s\Z'), + 'publishedBy' => 'system', + 'notes' => 'Seeded by OpenBuilt install — initial published version', + ], + register: 'openbuilt', + schema: 'application-version' + ); + + $snapshotData = $snapshot->jsonSerialize(); + $snapshotSelf = ($snapshotData['@self'] ?? []); + $snapshotUuid = ($snapshotSelf['id'] ?? ($snapshotSelf['uuid'] ?? ($snapshotData['uuid'] ?? null))); + + if ($snapshotUuid !== null) { + // Patch the Application with currentVersion pointing at the snapshot. + $this->objectService->saveObject( + object: [ + 'slug' => self::SEED_SLUG, + 'name' => 'Hello World', + 'description' => 'The canonical seed virtual app for OpenBuilt. Exercises index + detail + form page types.', + 'version' => self::SEED_VERSION, + 'status' => 'published', + 'manifest' => $seedManifest, + 'currentVersion' => $snapshotUuid, + ], + register: 'openbuilt', + schema: 'application' + ); + $output->info('Seeded initial ApplicationVersion '.$snapshotUuid.' (currentVersion set).'); + } } // Seed three sample HelloMessage objects. diff --git a/lib/Settings/openbuilt_register.json b/lib/Settings/openbuilt_register.json index a3a29391..e1f6ce33 100644 --- a/lib/Settings/openbuilt_register.json +++ b/lib/Settings/openbuilt_register.json @@ -54,6 +54,11 @@ "enum": ["draft", "published", "archived"], "default": "draft", "description": "Lifecycle state. Driven by x-openregister-lifecycle below — not by direct writes." + }, + "currentVersion": { + "type": "string", + "format": "uuid", + "description": "UUID of the most recent ApplicationVersion snapshot. Maintained by the snapshot-on-publish lifecycle action (see chain spec #6 openbuilt-versioning). Optional — never-published Applications carry no currentVersion." } }, "x-openregister-lifecycle": { @@ -75,12 +80,27 @@ "name": "publish", "from": "draft", "to": "published", - "description": "Make the virtual app reachable at /builder/{slug}.", + "description": "Make the virtual app reachable at /builder/{slug}. Also snapshots the current manifest into an ApplicationVersion sibling and bumps currentVersion (chain spec #6 openbuilt-versioning).", "on_transition": { "upsert_relation": { "schema": "openbuilt/built-app-route", "match": { "slug": "@self.slug" }, "payload": { "slug": "@self.slug", "applicationUuid": "@self.uuid" } + }, + "create_relation": { + "schema": "openbuilt/application-version", + "payload": { + "applicationUuid": "@self.uuid", + "version": "@self.version", + "manifest": "@self.manifest", + "publishedAt": "@now", + "publishedBy": "@actor", + "notes": "Published via lifecycle transition" + }, + "writeback": { + "self.currentVersion": "@result.uuid", + "self.status": "draft" + } } } }, @@ -159,6 +179,45 @@ "description": "Optional longer message body." } } + }, + "ApplicationVersion": { + "slug": "application-version", + "icon": "HistoryIcon", + "version": "0.1.0", + "title": "Application Version", + "description": "Append-only snapshot of an Application's manifest at the moment it was published. Created by the snapshot-on-publish lifecycle action (chain spec #6 openbuilt-versioning). History is never destructive — rollback creates a NEW snapshot instead of removing existing rows (design.md Decision 3).", + "type": "object", + "required": ["applicationUuid", "version", "manifest", "publishedAt", "publishedBy"], + "properties": { + "applicationUuid": { + "type": "string", + "format": "uuid", + "description": "UUID of the parent Application this snapshot belongs to." + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$", + "description": "Semver version string copied from the Application's `version` field at snapshot time." + }, + "manifest": { + "type": "object", + "description": "Full deep-copy of the Application's manifest blob at snapshot time. Per design.md Decision 1 this is a full blob, not a JSON Patch delta.", + "additionalProperties": true + }, + "publishedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp recording when the snapshot was taken (i.e. when the publish transition fired)." + }, + "publishedBy": { + "type": "string", + "description": "Nextcloud user id of the actor who triggered the publish. `system` for snapshots created by repair steps." + }, + "notes": { + "type": "string", + "description": "Optional free-text changelog entry. v1 leaves this populated by the lifecycle action with a default string — a future spec may add a notes-prompt UX." + } + } } } } From 66ea58e2e02b07e4146ff03342e6389ca225ecf2 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 11 May 2026 20:50:57 +0200 Subject: [PATCH 3/8] =?UTF-8?q?Apply=20openbuilt-versioning=20=E2=80=94=20?= =?UTF-8?q?frontend=20SFCs=20+=20review=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend (was WIP-salvage, quality now verified): - New views: VersionHistory.vue, ApplicationEditor (history/diff tabs) - New modals/components: RollbackConfirmModal, ManifestDiff Review fixes: 1. i18n key style flip — l10n/{en,nl}.json now use English-literal keys matching the t('openbuilt', 'English string') pattern in the SFCs. Dotted shorthand keys dropped; seed-record i18n keys (manifest labels/titles) stay dotted because they are persisted data, not UI literals. 2. PHPCS clean — auto-fix + manual rewrite of ApplicationVersionSnapshotListener (5 inline-if + 1 long line). 3. Listener::handle() split (CC=12 → 4 methods: isPublishTransition, snapshotPublish, createSnapshot, updateApplicationCurrentVersion, extractUuid, normaliseSerialised). 4. SeedHelloWorld::run() split (126 lines → seedApplicationAndRoute, seedInitialSnapshot, seedSampleMessages, seedAlreadyExists, extractUuid). 5. ESLint clean (ManifestDiff.vue:32 indent auto-fix). 6. Gate-7 IDOR — diffVersions docblock now documents the slug→BuiltAppRoute org-scope enforcement and the resolveVersionBlob applicationUuid check. 7. PHPStan green — IEventListener generic + snapshotPublish accepts Event with internal narrowing guard. Squashes WIP-salvage 77dcbe0. --- l10n/en.json | 66 ++-- l10n/nl.json | 66 ++-- lib/Controller/ApplicationsController.php | 5 + .../ApplicationVersionSnapshotListener.php | 268 ++++++++++---- lib/Repair/SeedHelloWorld.php | 281 +++++++++------ package-lock.json | 10 + package.json | 1 + src/components/ManifestDiff.vue | 201 +++++++++++ src/modals/RollbackConfirmModal.vue | 110 ++++++ src/views/ApplicationEditor.vue | 326 +++++++++++++++--- src/views/VersionHistory.vue | 251 ++++++++++++++ 11 files changed, 1298 insertions(+), 287 deletions(-) create mode 100644 src/components/ManifestDiff.vue create mode 100644 src/modals/RollbackConfirmModal.vue create mode 100644 src/views/VersionHistory.vue diff --git a/l10n/en.json b/l10n/en.json index f4df5247..e4e4147e 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -27,8 +27,13 @@ "Settings": "Settings", "Settings saved successfully": "Settings saved successfully", "Saving...": "Saving...", + "Saving…": "Saving…", "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.": "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.", "User settings will appear here in a future update.": "User settings will appear here in a future update.", + "Version Information": "Version Information", + "Information about the current OpenBuilt installation": "Information about the current OpenBuilt installation", + "Support": "Support", + "For support, contact us at": "For support, contact us at", "Virtual apps": "Virtual apps", "No virtual apps yet — seed `hello-world` should appear after install.": "No virtual apps yet — seed `hello-world` should appear after install.", @@ -37,42 +42,33 @@ "Integrator-only editor: edit the raw JSON manifest below. The visual editor lives in a follow-on release (openbuilt-page-editor).": "Integrator-only editor: edit the raw JSON manifest below. The visual editor lives in a follow-on release (openbuilt-page-editor).", "Paste or edit the JSON manifest here. See @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json for the canonical schema.": "Paste or edit the JSON manifest here. See @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json for the canonical schema.", "Invalid manifest": "Invalid manifest", - "Saving…": "Saving…", "Open virtual app": "Open virtual app", - - "openbuilt.helloworld.menu.messages": "Messages", - "openbuilt.helloworld.title.messages": "Hello World — messages", - "openbuilt.helloworld.title.message": "Message", - "openbuilt.helloworld.title.create": "New message", - "openbuilt.editor.help": "Integrator-only editor: edit the raw JSON manifest. Visual editor lives in chain spec openbuilt-page-editor.", - - "openbuilt.editor.publish": "Publish", - "openbuilt.editor.publishing": "Publishing…", - "openbuilt.editor.status.draft": "draft", - "openbuilt.editor.status.published": "published", - "openbuilt.editor.status.archived": "archived", - "openbuilt.editor.draftModified": "modified since last publish", - "openbuilt.editor.tab.editor": "Editor", - "openbuilt.editor.tab.history": "Version history", - "openbuilt.editor.tab.diff": "Diff", - "openbuilt.editor.publishedToast": "Published version {uuid}", - "openbuilt.versionHistory.title": "Version history", - "openbuilt.versionHistory.empty": "No versions yet — publish this app to create the first snapshot.", - "openbuilt.versionHistory.rollback": "Roll back to this version", - "openbuilt.versionHistory.compare": "Compare with current draft", - "openbuilt.versionHistory.publishedAt": "Published", - "openbuilt.versionHistory.publishedBy": "By", - "openbuilt.versionHistory.notes": "Notes", - "openbuilt.rollback.confirm.title": "Roll back to version {version}?", - "openbuilt.rollback.confirm.body": "Rolling back copies this snapshot's manifest onto the current draft. Existing history is preserved (append-only).", - "openbuilt.rollback.confirm.confirm": "Roll back", - "openbuilt.rollback.confirm.cancel": "Cancel", - "openbuilt.diff.title": "Manifest diff", - "openbuilt.diff.empty": "Nothing to diff — publish the app first.", - "openbuilt.diff.from": "From", - "openbuilt.diff.to": "To", - "openbuilt.diff.draft": "Current draft", - "openbuilt.diff.loading": "Loading diff…" + "Publish": "Publish", + "Publishing…": "Publishing…", + "draft": "draft", + "published": "published", + "archived": "archived", + "modified since last publish": "modified since last publish", + "Editor": "Editor", + "Version history": "Version history", + "Diff": "Diff", + "Published version {uuid}": "Published version {uuid}", + "No versions yet — publish this app to create the first snapshot.": "No versions yet — publish this app to create the first snapshot.", + "Roll back to this version": "Roll back to this version", + "Compare with current draft": "Compare with current draft", + "Published": "Published", + "By": "By", + "Loading…": "Loading…", + "Roll back to version {version}?": "Roll back to version {version}?", + "Rolling back copies this snapshot's manifest onto the current draft. Existing history is preserved (append-only).": "Rolling back copies this snapshot's manifest onto the current draft. Existing history is preserved (append-only).", + "Roll back": "Roll back", + "Cancel": "Cancel", + "Manifest diff": "Manifest diff", + "Nothing to diff — publish the app first.": "Nothing to diff — publish the app first.", + "From": "From", + "To": "To", + "Current draft": "Current draft", + "Loading diff…": "Loading diff…" }, "plurals": "" } diff --git a/l10n/nl.json b/l10n/nl.json index 3d0ffa3b..054c1ac9 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -27,8 +27,13 @@ "Settings": "Instellingen", "Settings saved successfully": "Instellingen succesvol opgeslagen", "Saving...": "Opslaan...", + "Saving…": "Opslaan…", "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.": "Deze app heeft OpenRegister nodig om gegevens op te slaan en te beheren. Installeer OpenRegister via de app store om te beginnen.", "User settings will appear here in a future update.": "Gebruikersinstellingen verschijnen hier in een toekomstige update.", + "Version Information": "Versie-informatie", + "Information about the current OpenBuilt installation": "Informatie over de huidige OpenBuilt-installatie", + "Support": "Ondersteuning", + "For support, contact us at": "Voor ondersteuning, neem contact op via", "Virtual apps": "Virtuele apps", "No virtual apps yet — seed `hello-world` should appear after install.": "Nog geen virtuele apps — de `hello-world`-seed zou na installatie zichtbaar moeten zijn.", @@ -37,42 +42,33 @@ "Integrator-only editor: edit the raw JSON manifest below. The visual editor lives in a follow-on release (openbuilt-page-editor).": "Editor voor integrators: bewerk hieronder het ruwe JSON-manifest. De visuele editor komt in een vervolg-release (openbuilt-page-editor).", "Paste or edit the JSON manifest here. See @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json for the canonical schema.": "Plak of bewerk hier het JSON-manifest. Zie @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json voor het canonieke schema.", "Invalid manifest": "Ongeldig manifest", - "Saving…": "Opslaan…", "Open virtual app": "Open virtuele app", - - "openbuilt.helloworld.menu.messages": "Berichten", - "openbuilt.helloworld.title.messages": "Hello World — berichten", - "openbuilt.helloworld.title.message": "Bericht", - "openbuilt.helloworld.title.create": "Nieuw bericht", - "openbuilt.editor.help": "Editor voor integrators: bewerk het ruwe JSON-manifest. De visuele editor komt in vervolgspec openbuilt-page-editor.", - - "openbuilt.editor.publish": "Publiceren", - "openbuilt.editor.publishing": "Publiceren…", - "openbuilt.editor.status.draft": "concept", - "openbuilt.editor.status.published": "gepubliceerd", - "openbuilt.editor.status.archived": "gearchiveerd", - "openbuilt.editor.draftModified": "gewijzigd sinds laatste publicatie", - "openbuilt.editor.tab.editor": "Editor", - "openbuilt.editor.tab.history": "Versiegeschiedenis", - "openbuilt.editor.tab.diff": "Vergelijken", - "openbuilt.editor.publishedToast": "Versie {uuid} gepubliceerd", - "openbuilt.versionHistory.title": "Versiegeschiedenis", - "openbuilt.versionHistory.empty": "Nog geen versies — publiceer deze app om de eerste snapshot te maken.", - "openbuilt.versionHistory.rollback": "Terugzetten naar deze versie", - "openbuilt.versionHistory.compare": "Vergelijken met huidig concept", - "openbuilt.versionHistory.publishedAt": "Gepubliceerd", - "openbuilt.versionHistory.publishedBy": "Door", - "openbuilt.versionHistory.notes": "Notities", - "openbuilt.rollback.confirm.title": "Terugzetten naar versie {version}?", - "openbuilt.rollback.confirm.body": "Bij terugzetten wordt het manifest van deze snapshot op het huidige concept geplaatst. Bestaande geschiedenis blijft behouden (alleen toevoegen).", - "openbuilt.rollback.confirm.confirm": "Terugzetten", - "openbuilt.rollback.confirm.cancel": "Annuleren", - "openbuilt.diff.title": "Manifest-vergelijking", - "openbuilt.diff.empty": "Niets te vergelijken — publiceer de app eerst.", - "openbuilt.diff.from": "Van", - "openbuilt.diff.to": "Naar", - "openbuilt.diff.draft": "Huidig concept", - "openbuilt.diff.loading": "Vergelijking laden…" + "Publish": "Publiceren", + "Publishing…": "Publiceren…", + "draft": "concept", + "published": "gepubliceerd", + "archived": "gearchiveerd", + "modified since last publish": "gewijzigd sinds laatste publicatie", + "Editor": "Editor", + "Version history": "Versiegeschiedenis", + "Diff": "Vergelijken", + "Published version {uuid}": "Versie {uuid} gepubliceerd", + "No versions yet — publish this app to create the first snapshot.": "Nog geen versies — publiceer deze app om de eerste snapshot te maken.", + "Roll back to this version": "Terugzetten naar deze versie", + "Compare with current draft": "Vergelijken met huidig concept", + "Published": "Gepubliceerd", + "By": "Door", + "Loading…": "Laden…", + "Roll back to version {version}?": "Terugzetten naar versie {version}?", + "Rolling back copies this snapshot's manifest onto the current draft. Existing history is preserved (append-only).": "Bij terugzetten wordt het manifest van deze snapshot op het huidige concept geplaatst. Bestaande geschiedenis blijft behouden (alleen toevoegen).", + "Roll back": "Terugzetten", + "Cancel": "Annuleren", + "Manifest diff": "Manifest-vergelijking", + "Nothing to diff — publish the app first.": "Niets te vergelijken — publiceer de app eerst.", + "From": "Van", + "To": "Naar", + "Current draft": "Huidig concept", + "Loading diff…": "Vergelijking laden…" }, "plurals": "" } diff --git a/lib/Controller/ApplicationsController.php b/lib/Controller/ApplicationsController.php index 47bff42e..8bc05f65 100644 --- a/lib/Controller/ApplicationsController.php +++ b/lib/Controller/ApplicationsController.php @@ -206,6 +206,11 @@ public function getManifest(string $slug): JSONResponse * @param string $to ApplicationVersion UUID or the literal `draft` * * @return JSONResponse Both blobs on 200, or a 404 envelope on miss + * + * IDOR-safe: slug → BuiltAppRoute lookup enforces org scope via OR's + * standard multitenancy (RegisterMapper::find + ObjectService::searchObjects), + * and the resolveVersionBlob() check on `applicationUuid` rejects snapshots + * that do not belong to this Application. Mirrors getManifest()'s pattern. */ #[NoAdminRequired] #[NoCSRFRequired] diff --git a/lib/Listener/ApplicationVersionSnapshotListener.php b/lib/Listener/ApplicationVersionSnapshotListener.php index ab1c77a5..e4890b86 100644 --- a/lib/Listener/ApplicationVersionSnapshotListener.php +++ b/lib/Listener/ApplicationVersionSnapshotListener.php @@ -54,16 +54,16 @@ /** * Snapshots an Application's manifest into ApplicationVersion on publish. * - * @template-implements IEventListener + * @template-implements IEventListener */ class ApplicationVersionSnapshotListener implements IEventListener { - private const REGISTER_SLUG = 'openbuilt'; - private const APPLICATION_SCHEMA = 'application'; - private const VERSION_SCHEMA = 'application-version'; - private const PUBLISH_FROM = 'draft'; - private const PUBLISH_TO = 'published'; - private const DEFAULT_PUBLISHED_BY = 'system'; + private const REGISTER_SLUG = 'openbuilt'; + private const APPLICATION_SCHEMA = 'application'; + private const VERSION_SCHEMA = 'application-version'; + private const PUBLISH_FROM = 'draft'; + private const PUBLISH_TO = 'published'; + private const DEFAULT_PUBLISHED_BY = 'system'; private const DEFAULT_SNAPSHOT_NOTES = 'Published via lifecycle transition'; /** @@ -83,12 +83,10 @@ public function __construct( /** * Handle the ObjectTransitionedEvent. * - * Filters on Application + draft→published, then creates the - * ApplicationVersion snapshot and writes back currentVersion + - * status reset on the Application. Failures are logged but - * never thrown — a snapshot failure must not block the - * underlying publish transition (it already succeeded by the - * time this listener runs). + * Filters on Application + draft→published, then delegates to + * `snapshotPublish()`. Failures are logged but never thrown — a + * snapshot failure must not block the underlying publish + * transition (it already succeeded by the time this listener runs). * * @param Event $event Dispatched event * @@ -96,81 +94,203 @@ public function __construct( */ public function handle(Event $event): void { - if ($event instanceof ObjectTransitionedEvent === false) { + if ($this->isPublishTransition(event: $event) === false) { return; } + try { + $this->snapshotPublish(event: $event); + } catch (\Throwable $e) { + // Never bubble up — the publish itself already succeeded; a + // failed snapshot must not roll the transition back. + $this->logger->error( + 'OpenBuilt: ApplicationVersionSnapshotListener failed: '.$e->getMessage(), + ['exception' => $e] + ); + } + }//end handle() + + /** + * Filter the incoming event to only the Application draft→published transition. + * + * @param Event $event Dispatched event. + * + * @return bool True when the event matches the snapshot trigger. + */ + private function isPublishTransition(Event $event): bool + { + if ($event instanceof ObjectTransitionedEvent === false) { + return false; + } + if ($event->getSchema() !== self::APPLICATION_SCHEMA) { - return; + return false; } - if ($event->getFrom() !== self::PUBLISH_FROM || $event->getTo() !== self::PUBLISH_TO) { + if ($event->getFrom() !== self::PUBLISH_FROM) { + return false; + } + + if ($event->getTo() !== self::PUBLISH_TO) { + return false; + } + + return true; + }//end isPublishTransition() + + /** + * Create the ApplicationVersion row and writeback Application.currentVersion. + * + * The caller must have passed the event through {@see isPublishTransition()} + * so the cast to ObjectTransitionedEvent is safe. + * + * @param Event $event The (already filtered) publish event. + * + * @return void + */ + private function snapshotPublish(Event $event): void + { + if ($event instanceof ObjectTransitionedEvent === false) { + // Belt-and-braces guard — should never trip thanks to handle()'s filter. return; } - try { - $applicationData = $event->getObject()->jsonSerialize(); - $applicationSelf = (is_array($applicationData) === true ? ($applicationData['@self'] ?? []) : []); - $applicationUuid = ($applicationSelf['id'] ?? ($applicationSelf['uuid'] ?? ($applicationData['uuid'] ?? null))); + $applicationData = $event->getObject()->jsonSerialize(); + $applicationUuid = $this->extractUuid(data: $applicationData); - if ($applicationUuid === null) { - $this->logger->warning('OpenBuilt: ApplicationVersionSnapshotListener could not resolve Application UUID; skipping snapshot.'); - return; - } + if ($applicationUuid === null) { + $this->logger->warning( + 'OpenBuilt: ApplicationVersionSnapshotListener could not resolve Application UUID; skipping snapshot.' + ); + return; + } - $manifest = ($applicationData['manifest'] ?? []); - $version = ($applicationData['version'] ?? '0.0.0'); - $userId = ($event->getUserId() ?? self::DEFAULT_PUBLISHED_BY); - - // Create the ApplicationVersion sibling row. OR's standard scoping - // (organisation + register + schema) applies on saveObject so the - // snapshot inherits the Application's org context. - $snapshot = $this->objectService->saveObject( - object: [ - 'applicationUuid' => $applicationUuid, - 'version' => $version, - 'manifest' => $manifest, - 'publishedAt' => gmdate('Y-m-d\TH:i:s\Z'), - 'publishedBy' => $userId, - 'notes' => self::DEFAULT_SNAPSHOT_NOTES, - ], - register: self::REGISTER_SLUG, - schema: self::VERSION_SCHEMA + $snapshot = $this->createSnapshot(applicationData: $applicationData, applicationUuid: $applicationUuid, event: $event); + $snapshotUuid = $this->extractUuid(data: $this->normaliseSerialised(object: $snapshot)); + + if ($snapshotUuid === null) { + $this->logger->warning( + 'OpenBuilt: ApplicationVersionSnapshotListener created a snapshot but could not read its UUID;' + .' currentVersion not updated.' ); + return; + } - $snapshotData = (method_exists($snapshot, 'jsonSerialize') === true ? $snapshot->jsonSerialize() : []); - $snapshotSelf = (is_array($snapshotData) === true ? ($snapshotData['@self'] ?? []) : []); - $snapshotUuid = ($snapshotSelf['id'] ?? ($snapshotSelf['uuid'] ?? ($snapshotData['uuid'] ?? null))); + $this->updateApplicationCurrentVersion(applicationData: $applicationData, snapshotUuid: $snapshotUuid); - if ($snapshotUuid === null) { - $this->logger->warning('OpenBuilt: ApplicationVersionSnapshotListener created a snapshot but could not read its UUID; currentVersion not updated.'); - return; - } + $this->logger->info( + 'OpenBuilt: snapshotted Application '.$applicationUuid.' as ApplicationVersion '.$snapshotUuid + .' (version '.($applicationData['version'] ?? '0.0.0').').' + ); + }//end snapshotPublish() - // Writeback — update Application.currentVersion AND reset status - // back to draft (design.md Decision 3: persistent status is `draft` - // when editable; "is published right now" is `currentVersion != null`). - $existing = (is_array($applicationData) === true ? $applicationData : []); - unset($existing['@self']); - $existing['currentVersion'] = $snapshotUuid; - $existing['status'] = self::PUBLISH_FROM; - - $this->objectService->saveObject( - object: $existing, - register: self::REGISTER_SLUG, - schema: self::APPLICATION_SCHEMA - ); + /** + * Save a new ApplicationVersion sibling row carrying a byte-equal manifest copy. + * + * @param array $applicationData Serialised Application data. + * @param string $applicationUuid Resolved Application UUID. + * @param ObjectTransitionedEvent $event The publish event (for actor id). + * + * @return mixed The OR-returned snapshot entity/array. + */ + private function createSnapshot(array $applicationData, string $applicationUuid, ObjectTransitionedEvent $event): mixed + { + $manifest = ($applicationData['manifest'] ?? []); + $version = ($applicationData['version'] ?? '0.0.0'); + $userId = ($event->getUserId() ?? self::DEFAULT_PUBLISHED_BY); - $this->logger->info( - 'OpenBuilt: snapshotted Application '.$applicationUuid.' as ApplicationVersion '.$snapshotUuid.' (version '.$version.').' - ); - } catch (\Throwable $e) { - // Never bubble up — the publish itself already succeeded; a - // failed snapshot must not roll the transition back. - $this->logger->error( - 'OpenBuilt: ApplicationVersionSnapshotListener failed: '.$e->getMessage(), - ['exception' => $e] - ); - }//end try - }//end handle() + // OR's standard scoping (organisation + register + schema) applies on + // saveObject so the snapshot inherits the Application's org context. + return $this->objectService->saveObject( + object: [ + 'applicationUuid' => $applicationUuid, + 'version' => $version, + 'manifest' => $manifest, + 'publishedAt' => gmdate('Y-m-d\TH:i:s\Z'), + 'publishedBy' => $userId, + 'notes' => self::DEFAULT_SNAPSHOT_NOTES, + ], + register: self::REGISTER_SLUG, + schema: self::VERSION_SCHEMA + ); + }//end createSnapshot() + + /** + * Writeback — point Application.currentVersion at the new snapshot and reset status. + * + * Design.md Decision 3: persistent status is `draft` when editable; + * "is published right now" is `currentVersion != null`. + * + * @param array $applicationData Serialised Application data. + * @param string $snapshotUuid UUID of the newly created snapshot. + * + * @return void + */ + private function updateApplicationCurrentVersion(array $applicationData, string $snapshotUuid): void + { + $existing = $applicationData; + unset($existing['@self']); + $existing['currentVersion'] = $snapshotUuid; + $existing['status'] = self::PUBLISH_FROM; + + $this->objectService->saveObject( + object: $existing, + register: self::REGISTER_SLUG, + schema: self::APPLICATION_SCHEMA + ); + }//end updateApplicationCurrentVersion() + + /** + * Read the canonical UUID out of an OR-serialised object array. + * + * Looks in `@self.id`, `@self.uuid`, then top-level `uuid`. + * + * @param array $data Serialised object array. + * + * @return string|null The UUID or null if not present. + */ + private function extractUuid(array $data): ?string + { + $self = []; + if (isset($data['@self']) === true && is_array($data['@self']) === true) { + $self = $data['@self']; + } + + if (isset($self['id']) === true) { + return (string) $self['id']; + } + + if (isset($self['uuid']) === true) { + return (string) $self['uuid']; + } + + if (isset($data['uuid']) === true) { + return (string) $data['uuid']; + } + + return null; + }//end extractUuid() + + /** + * Coerce an OR-returned entity/array to a plain associative array. + * + * @param mixed $object The OR object/result entry. + * + * @return array + */ + private function normaliseSerialised(mixed $object): array + { + if (is_array($object) === true) { + return $object; + } + + if (is_object($object) === true && method_exists($object, 'jsonSerialize') === true) { + $serialised = $object->jsonSerialize(); + if (is_array($serialised) === true) { + return $serialised; + } + } + + return []; + }//end normaliseSerialised() }//end class diff --git a/lib/Repair/SeedHelloWorld.php b/lib/Repair/SeedHelloWorld.php index 4d86b92c..42d5737b 100644 --- a/lib/Repair/SeedHelloWorld.php +++ b/lib/Repair/SeedHelloWorld.php @@ -81,117 +81,18 @@ public function run(IOutput $output): void $output->info('Seeding hello-world virtual app...'); try { - // Idempotency guard — if a hello-world Application already exists, do nothing. - $existing = $this->objectService->findAll( - config: [ - 'filters' => [ - 'register' => 'openbuilt', - 'schema' => 'application', - 'slug' => self::SEED_SLUG, - ], - 'limit' => 1, - ] - ); - - if (empty($existing) === false) { + if ($this->seedAlreadyExists() === true) { $output->info('hello-world Application already exists; skipping seed.'); return; } - // Create the Application object with the canonical hello-world manifest. - // NOTE (design.md OQ-1): OR's current x-openregister-lifecycle engine does - // not yet support `on_transition.upsert_relation` as a declarative action - // that creates a sibling object. Until OR ships that hook we explicitly - // create the BuiltAppRoute here. This is the ADR-031 §Exceptions(1) path. - $seedManifest = $this->buildHelloWorldManifest(); - $application = $this->objectService->saveObject( - object: [ - 'slug' => self::SEED_SLUG, - 'name' => 'Hello World', - 'description' => 'The canonical seed virtual app for OpenBuilt. Exercises index + detail + form page types.', - 'version' => self::SEED_VERSION, - 'status' => 'published', - 'manifest' => $seedManifest, - ], - register: 'openbuilt', - schema: 'application' - ); - - // ObjectEntity exposes its fields via jsonSerialize() (returns an array - // including the OR-assigned uuid). __call-based getters like getUuid() - // are invisible to method_exists, so we read through the array. - // OR places the canonical uuid under @self.id in the serialized shape. - $applicationData = $application->jsonSerialize(); - $applicationSelf = ($applicationData['@self'] ?? []); - $applicationUuid = ($applicationSelf['id'] ?? ($applicationSelf['uuid'] ?? $applicationData['uuid'] ?? null)); - - $output->info('Created hello-world Application (uuid='.($applicationUuid ?? 'unknown').').'); + $applicationUuid = $this->seedApplicationAndRoute(output: $output); - // Explicit BuiltAppRoute upkeep — fallback for the missing lifecycle hook. if ($applicationUuid !== null) { - $this->objectService->saveObject( - object: [ - 'slug' => self::SEED_SLUG, - 'applicationUuid' => $applicationUuid, - ], - register: 'openbuilt', - schema: 'built-app-route' - ); - $output->info('Created BuiltAppRoute for hello-world.'); - - // Seed one ApplicationVersion snapshot (chain spec #6 openbuilt-versioning) - // so the version-history panel is non-empty on a fresh install and the - // diff view has something to render in the walkthrough. Same - // ADR-031 §Exceptions(1) rationale as the BuiltAppRoute upkeep above — - // the lifecycle action declared on Application is not yet executed by - // OR's engine, so we wire the seed snapshot explicitly. The listener - // does the same on every subsequent publish. - $snapshot = $this->objectService->saveObject( - object: [ - 'applicationUuid' => $applicationUuid, - 'version' => self::SEED_VERSION, - 'manifest' => $seedManifest, - 'publishedAt' => gmdate('Y-m-d\TH:i:s\Z'), - 'publishedBy' => 'system', - 'notes' => 'Seeded by OpenBuilt install — initial published version', - ], - register: 'openbuilt', - schema: 'application-version' - ); - - $snapshotData = $snapshot->jsonSerialize(); - $snapshotSelf = ($snapshotData['@self'] ?? []); - $snapshotUuid = ($snapshotSelf['id'] ?? ($snapshotSelf['uuid'] ?? ($snapshotData['uuid'] ?? null))); - - if ($snapshotUuid !== null) { - // Patch the Application with currentVersion pointing at the snapshot. - $this->objectService->saveObject( - object: [ - 'slug' => self::SEED_SLUG, - 'name' => 'Hello World', - 'description' => 'The canonical seed virtual app for OpenBuilt. Exercises index + detail + form page types.', - 'version' => self::SEED_VERSION, - 'status' => 'published', - 'manifest' => $seedManifest, - 'currentVersion' => $snapshotUuid, - ], - register: 'openbuilt', - schema: 'application' - ); - $output->info('Seeded initial ApplicationVersion '.$snapshotUuid.' (currentVersion set).'); - } - } - - // Seed three sample HelloMessage objects. - foreach ($this->buildSampleMessages() as $message) { - $this->objectService->saveObject( - object: $message, - register: 'openbuilt', - schema: 'hello-message' - ); + $this->seedInitialSnapshot(output: $output, applicationUuid: $applicationUuid); } - $output->info('Seeded three sample HelloMessage objects.'); + $this->seedSampleMessages(output: $output); $this->logger->info('OpenBuilt: hello-world virtual app seeded successfully'); } catch (\Throwable $e) { @@ -203,6 +104,180 @@ public function run(IOutput $output): void }//end try }//end run() + /** + * Idempotency guard — true when a hello-world Application is already present. + * + * @return bool + */ + private function seedAlreadyExists(): bool + { + $existing = $this->objectService->findAll( + config: [ + 'filters' => [ + 'register' => 'openbuilt', + 'schema' => 'application', + 'slug' => self::SEED_SLUG, + ], + 'limit' => 1, + ] + ); + + return empty($existing) === false; + }//end seedAlreadyExists() + + /** + * Create the hello-world Application AND the BuiltAppRoute pointing at it. + * + * Returns the application UUID (or null if OR did not return one) so the + * caller can chain the initial snapshot seed. Per design.md OQ-1, OR's + * x-openregister-lifecycle engine does not yet support + * `on_transition.upsert_relation`, so the BuiltAppRoute is created + * explicitly — ADR-031 §Exceptions(1) declarative-first failure mode. + * + * @param IOutput $output Progress reporter. + * + * @return string|null The created Application's UUID, or null on miss. + */ + private function seedApplicationAndRoute(IOutput $output): ?string + { + $seedManifest = $this->buildHelloWorldManifest(); + $application = $this->objectService->saveObject( + object: [ + 'slug' => self::SEED_SLUG, + 'name' => 'Hello World', + 'description' => 'The canonical seed virtual app for OpenBuilt. Exercises index + detail + form page types.', + 'version' => self::SEED_VERSION, + 'status' => 'published', + 'manifest' => $seedManifest, + ], + register: 'openbuilt', + schema: 'application' + ); + + $applicationData = $application->jsonSerialize(); + $applicationUuid = $this->extractUuid(data: $applicationData); + + $output->info('Created hello-world Application (uuid='.($applicationUuid ?? 'unknown').').'); + + if ($applicationUuid === null) { + return null; + } + + $this->objectService->saveObject( + object: [ + 'slug' => self::SEED_SLUG, + 'applicationUuid' => $applicationUuid, + ], + register: 'openbuilt', + schema: 'built-app-route' + ); + $output->info('Created BuiltAppRoute for hello-world.'); + + return $applicationUuid; + }//end seedApplicationAndRoute() + + /** + * Seed one ApplicationVersion snapshot AND point Application.currentVersion at it. + * + * Chain spec #6 openbuilt-versioning — same ADR-031 §Exceptions(1) rationale + * as the BuiltAppRoute upkeep above. The listener does the same on every + * subsequent publish. + * + * @param IOutput $output Progress reporter. + * @param string $applicationUuid The parent Application's UUID. + * + * @return void + */ + private function seedInitialSnapshot(IOutput $output, string $applicationUuid): void + { + $seedManifest = $this->buildHelloWorldManifest(); + + $snapshot = $this->objectService->saveObject( + object: [ + 'applicationUuid' => $applicationUuid, + 'version' => self::SEED_VERSION, + 'manifest' => $seedManifest, + 'publishedAt' => gmdate('Y-m-d\TH:i:s\Z'), + 'publishedBy' => 'system', + 'notes' => 'Seeded by OpenBuilt install — initial published version', + ], + register: 'openbuilt', + schema: 'application-version' + ); + + $snapshotData = $snapshot->jsonSerialize(); + $snapshotUuid = $this->extractUuid(data: $snapshotData); + + if ($snapshotUuid === null) { + return; + } + + // Patch the Application with currentVersion pointing at the snapshot. + $this->objectService->saveObject( + object: [ + 'slug' => self::SEED_SLUG, + 'name' => 'Hello World', + 'description' => 'The canonical seed virtual app for OpenBuilt. Exercises index + detail + form page types.', + 'version' => self::SEED_VERSION, + 'status' => 'published', + 'manifest' => $seedManifest, + 'currentVersion' => $snapshotUuid, + ], + register: 'openbuilt', + schema: 'application' + ); + $output->info('Seeded initial ApplicationVersion '.$snapshotUuid.' (currentVersion set).'); + }//end seedInitialSnapshot() + + /** + * Seed three sample HelloMessage objects. + * + * @param IOutput $output Progress reporter. + * + * @return void + */ + private function seedSampleMessages(IOutput $output): void + { + foreach ($this->buildSampleMessages() as $message) { + $this->objectService->saveObject( + object: $message, + register: 'openbuilt', + schema: 'hello-message' + ); + } + + $output->info('Seeded three sample HelloMessage objects.'); + }//end seedSampleMessages() + + /** + * Read the canonical UUID out of an OR-serialised object array. + * + * @param array $data Serialised object array. + * + * @return string|null The UUID or null if not present. + */ + private function extractUuid(array $data): ?string + { + $self = []; + if (isset($data['@self']) === true && is_array($data['@self']) === true) { + $self = $data['@self']; + } + + if (isset($self['id']) === true) { + return (string) $self['id']; + } + + if (isset($self['uuid']) === true) { + return (string) $self['uuid']; + } + + if (isset($data['uuid']) === true) { + return (string) $data['uuid']; + } + + return null; + }//end extractUuid() + /** * Build the canonical hello-world manifest. * diff --git a/package-lock.json b/package-lock.json index e16dfdbd..cebc02c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@nextcloud/l10n": "^2.0.1", "@nextcloud/router": "^2.0.1", "@nextcloud/vue": "^8.16.0", + "diff": "^5.2.0", "pinia": "^2.1.7", "vue": "^2.7.14", "vue-material-design-icons": "^5.3.0", @@ -7814,6 +7815,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diffie-hellman": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", diff --git a/package.json b/package.json index 717bc34c..2aaa6514 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@nextcloud/l10n": "^2.0.1", "@nextcloud/router": "^2.0.1", "@nextcloud/vue": "^8.16.0", + "diff": "^5.2.0", "pinia": "^2.1.7", "vue": "^2.7.14", "vue-material-design-icons": "^5.3.0", diff --git a/src/components/ManifestDiff.vue b/src/components/ManifestDiff.vue new file mode 100644 index 00000000..4babc327 --- /dev/null +++ b/src/components/ManifestDiff.vue @@ -0,0 +1,201 @@ + + + + + + diff --git a/src/modals/RollbackConfirmModal.vue b/src/modals/RollbackConfirmModal.vue new file mode 100644 index 00000000..5b58a48d --- /dev/null +++ b/src/modals/RollbackConfirmModal.vue @@ -0,0 +1,110 @@ + + + + + + diff --git a/src/views/ApplicationEditor.vue b/src/views/ApplicationEditor.vue index adc5c1f6..d50b55de 100644 --- a/src/views/ApplicationEditor.vue +++ b/src/views/ApplicationEditor.vue @@ -8,6 +8,11 @@ - - Per ADR-022 the editor reads/writes Application objects via OR's - REST API directly — no app-local CRUD wrapper. + - + - Chain spec #6 (openbuilt-versioning) adds the Publish action, + - status + "draft modified" badges, version-history sibling panel, + - and manifest-diff component. Each lives in its own SFC per Hydra + - modal-isolation + composition rules. -->