Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4f37c1b
Apply bootstrap-openbuilt — core implementation (tasks 1.1–1.5, 2.1–2…
rubenvdlinde May 11, 2026
87ed2a1
Quality pass — DI refactors, method split, SPDX placement, dep bump (…
rubenvdlinde May 11, 2026
777daf9
Apply bootstrap-openbuilt — docs + minimum tests + capabilities (task…
rubenvdlinde May 11, 2026
2d285e8
Fix hydra-gate-10 + hydra-gate-11 security findings surfaced by /opsx…
rubenvdlinde May 11, 2026
3138e4c
Runtime smoke test — fix 5 real bugs surfaced by docker exec app:enab…
rubenvdlinde May 11, 2026
8df1450
feat(openspec): add openbuilt-page-editor change artifacts
rubenvdlinde May 11, 2026
3b49c86
wip(page-editor): partial sub-editor scaffold from prior attempt
rubenvdlinde May 11, 2026
36c58c8
feat(page-editor): MVP visual designer (PageDesigner + ApplicationEdi…
rubenvdlinde May 11, 2026
7c00138
docs(page-editor): mark MVP tasks complete; flag deferred items as v1.1
rubenvdlinde May 11, 2026
caf5a76
refactor(page-editor): replace custom Pinia + raw axios with createOb…
rubenvdlinde May 11, 2026
fa961ef
docs(i18n): add 119 missing page-editor keys (en + nl)
rubenvdlinde May 11, 2026
543540c
test(page-editor): vitest setup + composable specs (18 tests)
rubenvdlinde May 11, 2026
9626583
test(page-editor): MenuTreeEditor + PageListEditor specs (26 tests)
rubenvdlinde May 11, 2026
b527d01
test(page-editor): Index + Form + PageDesigner specs (45 tests)
rubenvdlinde May 11, 2026
ca0839e
test(page-editor): Playwright e2e infra + 2 scenarios
rubenvdlinde May 11, 2026
afeb305
test(page-editor): Newman integration coverage (5 requests)
rubenvdlinde May 11, 2026
d3df062
fix(ci): regenerate lockfile + stylelint auto-fixes
rubenvdlinde May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ Vrij en open source onder de EUPL-1.2-licentie.
<repair-steps>
<install>
<step>OCA\OpenBuilt\Repair\InitializeSettings</step>
<step>OCA\OpenBuilt\Repair\SeedHelloWorld</step>
</install>
<post-migration>
<step>OCA\OpenBuilt\Repair\InitializeSettings</step>
<step>OCA\OpenBuilt\Repair\SeedHelloWorld</step>
</post-migration>
</repair-steps>
</info>
7 changes: 7 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
// Health check endpoint.
['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'],

// Manifest endpoint — returns the stored manifest JSON blob for a given virtual-app slug.
// Per ADR-016 routes.php is the only registration path; #[NoAdminRequired] is set on the
// controller method so auth-required-but-non-admin users can hit it (per design.md Decision 6).
// Slug matches the kebab-case pattern declared in openbuilt_register.json on the Application
// 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]']],

// 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' => '']],
Expand Down
75 changes: 75 additions & 0 deletions docs/integrator-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Integrator guide — authoring a virtual app by hand

This guide walks you through creating a new virtual app in OpenBuilt without using the visual editor (which lives in chain spec [`openbuilt-page-editor`](../openspec/changes/) — not yet shipped). At this stage, OpenBuilt is integrator-only: you write JSON and the runtime renders it.

## What you author

A virtual app is one record in OpenBuilt's `Application` OR schema. The shape is:

```jsonc
{
"slug": "permit-tracker", // kebab-case, 2–48 chars
"name": "Permit Tracker",
"description": "Track building permits through review stages.",
"version": "0.1.0",
"status": "draft", // draft → published → archived
"manifest": {
"version": "1.0.0",
"dependencies": ["openregister"],
"menu": [ ... ],
"pages": [ ... ]
}
}
```

The `manifest` object validates against [`@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json`](https://github.com/ConductionNL/nextcloud-vue/blob/main/src/schemas/app-manifest.schema.json). The closed `type` enum for pages is `index | detail | dashboard | logs | settings | chat | files | form | custom`.

## Step-by-step

1. **Pick a slug.** Must be kebab-case, 2–48 chars, unique within your organisation. The synthetic appId in CnAppRoot becomes `openbuilt-${slug}`.
2. **Design your schemas** in OpenRegister directly (the OpenBuilt schema editor lands in chain spec `openbuilt-schema-editor`). At minimum: one schema per primary entity your app shows.
3. **Author the manifest** as JSON. The canonical example is the seeded `hello-world` Application — open it in OpenBuilt's manifest editor (top-bar OpenBuilt entry → Virtual apps → hello-world) and read its manifest.
4. **Save as `draft`** while iterating. The textarea editor validates each save against the canonical schema; you see the failing JSON path on save error.
5. **Transition to `published`** when ready (via OR's lifecycle endpoint or the editor's Publish action — landing in chain spec `openbuilt-versioning`). On publish, OpenBuilt's lifecycle creates the corresponding `BuiltAppRoute` so `/builder/{slug}` becomes reachable.

## Manifest checklist

Per [ADR-024](https://github.com/ConductionNL/hydra/blob/main/openspec/architecture/adr-024-app-manifest.md):

- `version` (semver) — your app's content version
- `dependencies` — list of NC app IDs that must be installed (almost always `["openregister"]`)
- `menu[]` — at least one entry; supports one level of `children[]`
- `pages[]` — at least one entry; every page's `id` MUST be unique and match a vue-router route name
- `label` / `title` strings are i18n KEYS, not literals. The consuming app's `t()` resolves them. Use kebab.dot.notation: `myapp.permits.title.list`.

Per [ADR-007](https://github.com/ConductionNL/hydra/blob/main/openspec/architecture/adr-007-i18n.md):

- Every translation key MUST exist in `l10n/en.json` AND `l10n/nl.json` of the **OpenBuilt** repo (until per-virtual-app translations land in chain spec `openbuilt-page-editor`).

## Reading the seed manifest

The seeded `hello-world` Application is the canonical reference. Its manifest exercises:

- **index** page → drives `CnIndexPage` with `register: openbuilt`, `schema: hello-message`, three columns
- **detail** page → drives `CnDetailPage` keyed on `:id`
- **form** page → drives `CnFormPage` with `mode: create` and `submitEndpoint` going to OR's REST

See [`lib/Repair/SeedHelloWorld.php`](../lib/Repair/SeedHelloWorld.php) `buildHelloWorldManifest()` for the full JSON.

## When you hit a limit

The closed `type` enum can't be extended from a manifest — adding a new page type requires a library-level openspec change in `@conduction/nextcloud-vue`. If you need something the four built-in types can't express:

1. Confirm the requirement isn't satisfied by `form` (the most flexible built-in).
2. Open an issue on `ConductionNL/nextcloud-vue` describing the new page type's shape.
3. As an interim, mount a custom Vue component via `type: "custom"` + `component: "MyCustomPage"` and register the component in OpenBuilt's `customComponents` map. (Note: spec #1 only ships the built-in types — the `customComponents` registry surface lands when a real consumer needs it.)

## What does NOT work yet (spec #1 limitations)

- **No visual editor** — JSON textarea only. Visual editor: chain spec `openbuilt-page-editor`.
- **No schema designer** — schemas must be authored in `lib/Settings/openbuilt_register.json` and imported via the repair step. Runtime schema authoring: chain spec `openregister-runtime-schema-api`.
- **No draft preview** — only `published` apps appear at `/builder/{slug}`. Draft preview: chain spec `openbuilt-versioning`.
- **No per-app permissions** — auth-only visibility for v1; everyone in your organisation sees every virtual app. Per-app RBAC: chain spec `openbuilt-rbac`.
- **No export to a real Nextcloud app** — virtual-only. Export pipeline: chain spec `openbuilt-export-to-real-app`.

If any of these limitations block your project, talk to Conduction — chain spec prioritisation can shift.
119 changes: 119 additions & 0 deletions docs/openbuilt-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# OpenBuilt Runtime

This document describes how OpenBuilt renders a virtual app at runtime — the manifest endpoint, the nested `CnAppRoot` mount, and the workaround that bridges the gap until the in-memory `useAppManifest` overload ships in `@conduction/nextcloud-vue`.

> Scope: spec #1 (`bootstrap-openbuilt`) of the 9-spec OpenBuilt chain. Visual editors, draft/publish lifecycle UX, per-app RBAC, marketplace, and code export live in chained follow-on specs.

## Big picture

```
[ Browser request ]
[ OpenBuilt shell — outer CnAppRoot owned by openbuilt/src/manifest.json ]
│ navigate to /builder/<slug>/...
[ src/views/BuilderHost.vue — mounts a NESTED CnAppRoot ]
│ useAppManifest( appId='openbuilt-<slug>', placeholderManifest, options )
[ options.endpoint → GET /index.php/apps/openbuilt/api/applications/<slug>/manifest ]
[ ApplicationsController::getManifest( slug ) ]
│ via OR's ObjectService:
[ openbuilt/built-app-route → applicationUuid ]
[ openbuilt/application[uuid].manifest ]
[ unwrapped manifest JSON → useAppManifest deep-merges with placeholder → CnAppRoot renders ]
```

## Why a nested CnAppRoot

`CnAppRoot` is router-agnostic and accepts a `manifest` prop. OpenBuilt mounts a **fresh** instance per virtual app inside its own shell at `/builder/{slug}/*`. The `:key="slug"` prop forces a clean remount when the user navigates between virtual apps, so the inner manifest's router resets cleanly.

Alternatives rejected (see `openspec/changes/bootstrap-openbuilt/design.md` Decision 5):

- Replacing the outer router for the duration of the virtual-app session — breaks the "where am I?" mental model.
- Opening the virtual app in a new tab — loses state, breaks the back button, forces a full Nextcloud reload.

## The manifest endpoint

| | |
|---|---|
| **URL** | `GET /index.php/apps/openbuilt/api/applications/{slug}/manifest` |
| **Auth** | `#[NoAdminRequired]` + `#[NoCSRFRequired]` (auth-only for v1; scoping comes from OR's organisation field per ADR-022) |
| **Slug pattern** | `^[a-z0-9][a-z0-9-]*[a-z0-9]$`, 2–48 chars (matches the schema declaration) |
| **Lookup path** | slug → `openbuilt/built-app-route` → applicationUuid → `openbuilt/application` → `manifest` |
| **Response (200)** | the manifest JSON blob, **unwrapped** (no OR envelope) so `useAppManifest` consumes it directly |
| **Response (404)** | when no `BuiltAppRoute` matches the slug (i.e. no published app at that path) |
| **Response (500)** | inconsistent state (route → missing application, or application → missing manifest) — logged at warning |
| **Controller** | [lib/Controller/ApplicationsController.php](../lib/Controller/ApplicationsController.php) |

The controller is intentionally thin (~50 LOC of method body): a slug lookup, a UUID lookup, and an unwrap. All other CRUD on `Application` + `BuiltAppRoute` goes through OR's REST API directly per ADR-022.

## The workaround — bundled-mode `useAppManifest` with redirected endpoint

`@conduction/nextcloud-vue` v1.0.0-beta.30 ships `useAppManifest(appId, bundledManifest, options)` which fetches from `/index.php/apps/{appId}/api/manifest` by default — but it accepts an `options.endpoint` override to redirect the fetch.

OpenBuilt uses this:

```vue
<!-- src/views/BuilderHost.vue -->
<CnAppRoot
:key="slug"
:app-id="`openbuilt-${slug}`"
:bundled-manifest="placeholderManifest"
:options="{ endpoint: generateUrl(`/apps/openbuilt/api/applications/${slug}/manifest`) }" />
```

- `appId = openbuilt-${slug}` makes each virtual app's manifest cache key unique.
- `bundledManifest` is a minimal placeholder (`{ version: '0.0.0', menu: [], pages: [] }`) shipped at [`src/manifests/placeholder.json`](../src/manifests/placeholder.json). `useAppManifest` synchronously seeds with this then deep-merges the backend response.
- `options.endpoint` redirects the backend fetch from the default `/apps/openbuilt-${slug}/api/manifest` (which would 404 — that's a different "app") to OpenBuilt's per-slug endpoint.

When `nextcloud-vue` later ships an in-memory overload `useAppManifest({ manifest: object })` (chain spec #2 = `nextcloud-vue-in-memory-manifest`), `BuilderHost.vue` collapses to that call and the per-slug endpoint becomes optional. Until then, the endpoint stays on the critical path.

## The lifecycle is declarative (ADR-031)

OpenBuilt does **not** ship an `ApplicationLifecycleService.php` / `ApplicationStateMachine.php` / similar service class. The state machine lives in the schema register at [lib/Settings/openbuilt_register.json](../lib/Settings/openbuilt_register.json) under `Application.x-openregister-lifecycle`:

| State | Transition | Action |
|---|---|---|
| `draft` → `published` | `publish` | upsert sibling `BuiltAppRoute(slug, applicationUuid)` |
| `published` → `archived` | `archive` | delete `BuiltAppRoute` with matching slug |
| `archived` → `draft` | `reopen` | — |
| `archived` → `published` | `republish` | upsert `BuiltAppRoute` |

> If OR's current lifecycle engine doesn't yet support the `on_transition.upsert_relation` / `delete_relation` actions for sibling-object upkeep, the fallback is a single PHP listener `lib/Listener/BuiltAppRouteSyncListener.php` subscribed to `ObjectLifecycleTransitionedEvent` (per design.md OQ-1). The listener is the ADR-031 §Exceptions(1) path; behaviour from the user's perspective is identical either way.

## Seed: `hello-world`

`lib/Repair/SeedHelloWorld.php` runs idempotently on every install + post-migration:

1. Guard on `openbuilt/application` slug `hello-world` — if present, no-op.
2. Save one `Application` (`slug: hello-world`, `status: published`, version `0.1.0`) with a manifest exercising `index`, `detail`, and `form` page types against the seeded `hello-message` schema.
3. Save three sample `hello-message` objects.

The seed gives integrators a working virtual app on minute one of an OpenBuilt install — browse to `/index.php/apps/openbuilt/builder/hello-world` post-install.

## File map

| Path | Role |
|------|------|
| [`appinfo/routes.php`](../appinfo/routes.php) | Registers `GET /api/applications/{slug}/manifest` |
| [`lib/Controller/ApplicationsController.php`](../lib/Controller/ApplicationsController.php) | `getManifest()` — the only app-local controller method |
| [`lib/Settings/openbuilt_register.json`](../lib/Settings/openbuilt_register.json) | OR schema declarations for `Application`, `BuiltAppRoute`, `HelloMessage`, plus the lifecycle metadata |
| [`lib/Repair/InitializeSettings.php`](../lib/Repair/InitializeSettings.php) | Imports the register into OR on install/upgrade |
| [`lib/Repair/SeedHelloWorld.php`](../lib/Repair/SeedHelloWorld.php) | Seeds the canonical hello-world virtual app |
| [`src/views/BuilderHost.vue`](../src/views/BuilderHost.vue) | Nested CnAppRoot mount with the redirected endpoint workaround |
| [`src/views/ApplicationEditor.vue`](../src/views/ApplicationEditor.vue) | Textarea-based JSON manifest editor (v1; visual editor lives in chain spec `openbuilt-page-editor`) |
| [`src/router/index.js`](../src/router/index.js) | Outer routes including `/builder/:slug/:pathMatch(.*)?` |
| [`src/manifests/placeholder.json`](../src/manifests/placeholder.json) | Empty-skeleton manifest bundled into `useAppManifest` |

## Related ADRs

- **ADR-022** — apps consume OpenRegister abstractions (OpenBuilt does not wrap OR's REST)
- **ADR-024** — app manifest standard (`CnAppRoot` + `CnAppNav` + `CnPageRenderer` + `useAppManifest`)
- **ADR-031** — schema-declarative business logic (the Application lifecycle is the canonical example)
- **ADR-032** — spec sizing (`bootstrap-openbuilt` is `kind: mixed` under the thin-glue exception)
7 changes: 5 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@ module.exports = defineConfig([{
},

rules: {
// Allow unused i18n functions (t, n) — imported for future translation wiring
'no-unused-vars': ['error', { varsIgnorePattern: '^(t|n)$', argsIgnorePattern: '^_' }],
// Allow unused i18n functions (t, n) — imported for future translation wiring.
// Allow leading-underscore vars (idiomatic "discarded destructure" — `const { foo: _foo, ...rest } = x`).
'no-unused-vars': ['error', { varsIgnorePattern: '^(t|n|_)', argsIgnorePattern: '^_' }],
'jsdoc/require-jsdoc': 'off',
'vue/first-attribute-linebreak': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'n/no-missing-import': 'off',
'n/no-unpublished-import': 'off', // vuedraggable is in dependencies; aliased nextcloud-vue isn't always resolvable to a published package
'import/named': 'off', // re-exports through aliased nextcloud-vue/src trip the resolver; webpack handles it at build time
'import/namespace': 'off', // disable namespace checking to avoid parser requirement
'import/default': 'off', // disable default import checking to avoid parser requirement
'import/no-named-as-default': 'off', // disable named-as-default checking to avoid parser requirement
Expand Down
Loading
Loading