Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,9 @@ openspec/test-site-results/**/*.jpg
openspec/test-site-results/**/*.jpeg
openspec/test-site-results/**/*.gif
openspec/test-site-results/**/*.webp

# Playwright artefacts (e2e tests live in tests/e2e/, the artefacts do not).
/playwright-report/
/test-results/
/.playwright/
/playwright/.cache/
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)
4 changes: 2 additions & 2 deletions img/app-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 5 additions & 4 deletions img/app-store.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion img/app.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 17 additions & 1 deletion l10n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,23 @@
"Settings saved successfully": "Settings saved successfully",
"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."
"User settings will appear here in a future update.": "User settings will appear here in a future update.",

"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.",
"Status": "Status",
"Version": "Version",
"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."
},
"plurals": ""
}
Loading
Loading