diff --git a/docs/integrator-guide.md b/docs/integrator-guide.md index 54242795..8857cc45 100644 --- a/docs/integrator-guide.md +++ b/docs/integrator-guide.md @@ -24,7 +24,42 @@ A virtual app is one record in OpenBuilt's `Application` OR schema. The shape is 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 +## Creating a virtual app with the wizard + +For most operators the visual wizard at **Virtual apps → New application** is the +quicker path. It walks you through three steps in one round-trip: + +1. **Identity** — pick a slug + human-readable name + description. +2. **Versions** — accept the default chain (`development → staging → production`) + or adjust it. Each version maps to its own per-version register + (`openbuilt-{slug}-{versionSlug}`) so production data is physically isolated + from staging and development. The wizard's chain editor enforces ADR-002's + linear-chain rule (no fan-out, no cycles, exactly one terminal `production` + tier). +3. **Permissions** — pick the owners / editors / viewers. The caller is + pre-filled into `owners`. Group `group:*` becomes the "all signed-in users" + wildcard per REQ-OBRBAC-004. + +On submit the wizard atomically creates the `Application` record, the N +`ApplicationVersion` rows, and N per-version registers — and seeds each +register with the default schema set (`hello-message` by default) under the +namespaced slug `{appSlug}-{versionSlug}-{originalSchemaSlug}`. The manifest's +`config.register` and `config.schema` pointers are rewritten to match +(`openbuilt-{slug}-{tier}` and `{appSlug}-{tier}-hello-message` respectively) +so the insights service and the runtime each address the right per-tier slice. + +**Empty-state landing** — fresh installs no longer auto-seed a `hello-world` +Application (the legacy `SeedHelloWorld` repair step was retired by +`openbuilt-versioning-model`). New deploys land the admin on an empty Virtual +apps index with a CTA pointing at the wizard. Pre-existing installs are not +affected; the migration step `MigrateToVersionedModel` only fires when +pre-spec-C Application rows are present and is idempotent on re-runs. + +For further reading on what each step writes through to OR, see +[`openbuilt-runtime.md`](./openbuilt-runtime.md) and the wizard chain spec +[`openspec/changes/openbuilt-app-creation-wizard/`](../openspec/changes/openbuilt-app-creation-wizard/). + +## Step-by-step (manual / integrator path) 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. diff --git a/docs/openbuilt-runtime.md b/docs/openbuilt-runtime.md index a03d1808..fb3698d7 100644 --- a/docs/openbuilt-runtime.md +++ b/docs/openbuilt-runtime.md @@ -87,15 +87,45 @@ OpenBuilt does **not** ship an `ApplicationLifecycleService.php` / `ApplicationS > 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. +## Creating a virtual app (wizard flow) + +Most operators reach the virtual app via the visual wizard at **Virtual apps → +New application**. The flow is owned by `ApplicationCreationController` + +`ApplicationCreationService` and lands in a single transactional round-trip: + +1. **Submit** the wizard's three-step payload (`identity`, `versions[]`, + `permissions`) to `POST /api/applications/wizard`. +2. The service creates the parent `Application` row, then per version: + - one `ApplicationVersion` row carrying the manifest, register pointer, and + semver, + - one per-version OR register `openbuilt-{appSlug}-{versionSlug}`, + - the default schema set (currently just `hello-message`) seeded into the + per-version register under namespaced slugs + (`{appSlug}-{versionSlug}-{originalSlug}`). +3. The first version in the chain's `productionVersion` pointer is set on the + parent Application. +4. The wizard rewrites the manifest's `pages[].config.register` and + `pages[].config.schema` to the namespaced per-version values before save — + so the runtime + insights service address the right per-tier slice (see + `ApplicationCreationService::substituteVersionContext()`). + +Rollback is best-effort: if any step fails the service tears down the registers ++ application + version rows it managed to create so the org-wide unique slug +isn't squatted. + +### Empty-state landing (no auto-seed) + +The legacy `lib/Repair/SeedHelloWorld.php` repair step was retired by +[`openbuilt-versioning-model`](../openspec/changes/openbuilt-versioning-model/). +Fresh installs land the admin on an empty Virtual apps index with a CTA +pointing at the wizard; pre-existing installs that still carry pre-spec-C +Application rows are migrated by `MigrateToVersionedModel` (destructive but +idempotent — see ADR-002). + +The `hello-world` slug now ships only as part of the wizard's +`default-manifest.json`/`default-schemas.json` blueprint, so every freshly +provisioned app gets the same one-index-one-detail-one-form starter and the +`/builder/{slug}` route resolves the moment publish fires. ## File map diff --git a/docs/static/screenshots/tutorials/admin/01-rbac-01.png b/docs/static/screenshots/tutorials/admin/01-rbac-01.png index 6b312f8e..8ed140b6 100644 Binary files a/docs/static/screenshots/tutorials/admin/01-rbac-01.png and b/docs/static/screenshots/tutorials/admin/01-rbac-01.png differ diff --git a/docs/static/screenshots/tutorials/admin/01-rbac-02.png b/docs/static/screenshots/tutorials/admin/01-rbac-02.png index 6b312f8e..8ed140b6 100644 Binary files a/docs/static/screenshots/tutorials/admin/01-rbac-02.png and b/docs/static/screenshots/tutorials/admin/01-rbac-02.png differ diff --git a/docs/static/screenshots/tutorials/admin/01-rbac-03.png b/docs/static/screenshots/tutorials/admin/01-rbac-03.png index 6b312f8e..8ed140b6 100644 Binary files a/docs/static/screenshots/tutorials/admin/01-rbac-03.png and b/docs/static/screenshots/tutorials/admin/01-rbac-03.png differ diff --git a/docs/static/screenshots/tutorials/admin/01-rbac-04.png b/docs/static/screenshots/tutorials/admin/01-rbac-04.png index 6b312f8e..8ed140b6 100644 Binary files a/docs/static/screenshots/tutorials/admin/01-rbac-04.png and b/docs/static/screenshots/tutorials/admin/01-rbac-04.png differ diff --git a/docs/static/screenshots/tutorials/admin/01-rbac-05.png b/docs/static/screenshots/tutorials/admin/01-rbac-05.png index 6b312f8e..8ed140b6 100644 Binary files a/docs/static/screenshots/tutorials/admin/01-rbac-05.png and b/docs/static/screenshots/tutorials/admin/01-rbac-05.png differ diff --git a/docs/static/screenshots/tutorials/admin/02-template-catalogue-01.png b/docs/static/screenshots/tutorials/admin/02-template-catalogue-01.png index 5d5eb17e..4a04fda7 100644 Binary files a/docs/static/screenshots/tutorials/admin/02-template-catalogue-01.png and b/docs/static/screenshots/tutorials/admin/02-template-catalogue-01.png differ diff --git a/docs/static/screenshots/tutorials/admin/02-template-catalogue-02.png b/docs/static/screenshots/tutorials/admin/02-template-catalogue-02.png index 04b89c4e..f6a0ec33 100644 Binary files a/docs/static/screenshots/tutorials/admin/02-template-catalogue-02.png and b/docs/static/screenshots/tutorials/admin/02-template-catalogue-02.png differ diff --git a/docs/static/screenshots/tutorials/admin/02-template-catalogue-03.png b/docs/static/screenshots/tutorials/admin/02-template-catalogue-03.png index 04b89c4e..f6a0ec33 100644 Binary files a/docs/static/screenshots/tutorials/admin/02-template-catalogue-03.png and b/docs/static/screenshots/tutorials/admin/02-template-catalogue-03.png differ diff --git a/docs/static/screenshots/tutorials/admin/02-template-catalogue-04.png b/docs/static/screenshots/tutorials/admin/02-template-catalogue-04.png index 4c5aa20b..9fb03c7b 100644 Binary files a/docs/static/screenshots/tutorials/admin/02-template-catalogue-04.png and b/docs/static/screenshots/tutorials/admin/02-template-catalogue-04.png differ diff --git a/docs/static/screenshots/tutorials/admin/02-template-catalogue-05.png b/docs/static/screenshots/tutorials/admin/02-template-catalogue-05.png index 4c5aa20b..9fb03c7b 100644 Binary files a/docs/static/screenshots/tutorials/admin/02-template-catalogue-05.png and b/docs/static/screenshots/tutorials/admin/02-template-catalogue-05.png differ diff --git a/docs/static/screenshots/tutorials/admin/03-admin-settings-01.png b/docs/static/screenshots/tutorials/admin/03-admin-settings-01.png index 6b312f8e..8ed140b6 100644 Binary files a/docs/static/screenshots/tutorials/admin/03-admin-settings-01.png and b/docs/static/screenshots/tutorials/admin/03-admin-settings-01.png differ diff --git a/docs/static/screenshots/tutorials/admin/03-admin-settings-02.png b/docs/static/screenshots/tutorials/admin/03-admin-settings-02.png index 6b312f8e..8ed140b6 100644 Binary files a/docs/static/screenshots/tutorials/admin/03-admin-settings-02.png and b/docs/static/screenshots/tutorials/admin/03-admin-settings-02.png differ diff --git a/docs/static/screenshots/tutorials/admin/03-admin-settings-03.png b/docs/static/screenshots/tutorials/admin/03-admin-settings-03.png index 6b312f8e..8ed140b6 100644 Binary files a/docs/static/screenshots/tutorials/admin/03-admin-settings-03.png and b/docs/static/screenshots/tutorials/admin/03-admin-settings-03.png differ diff --git a/docs/static/screenshots/tutorials/admin/03-admin-settings-04.png b/docs/static/screenshots/tutorials/admin/03-admin-settings-04.png index 6b312f8e..8ed140b6 100644 Binary files a/docs/static/screenshots/tutorials/admin/03-admin-settings-04.png and b/docs/static/screenshots/tutorials/admin/03-admin-settings-04.png differ diff --git a/docs/static/screenshots/tutorials/admin/03-admin-settings-05.png b/docs/static/screenshots/tutorials/admin/03-admin-settings-05.png index 6b312f8e..8ed140b6 100644 Binary files a/docs/static/screenshots/tutorials/admin/03-admin-settings-05.png and b/docs/static/screenshots/tutorials/admin/03-admin-settings-05.png differ diff --git a/docs/static/screenshots/tutorials/user/01-first-launch-01.png b/docs/static/screenshots/tutorials/user/01-first-launch-01.png index 27104979..ef450b3a 100644 Binary files a/docs/static/screenshots/tutorials/user/01-first-launch-01.png and b/docs/static/screenshots/tutorials/user/01-first-launch-01.png differ diff --git a/docs/static/screenshots/tutorials/user/01-first-launch-02.png b/docs/static/screenshots/tutorials/user/01-first-launch-02.png index 27104979..ef450b3a 100644 Binary files a/docs/static/screenshots/tutorials/user/01-first-launch-02.png and b/docs/static/screenshots/tutorials/user/01-first-launch-02.png differ diff --git a/docs/static/screenshots/tutorials/user/01-first-launch-03.png b/docs/static/screenshots/tutorials/user/01-first-launch-03.png index 27104979..ef450b3a 100644 Binary files a/docs/static/screenshots/tutorials/user/01-first-launch-03.png and b/docs/static/screenshots/tutorials/user/01-first-launch-03.png differ diff --git a/docs/static/screenshots/tutorials/user/01-first-launch-04.png b/docs/static/screenshots/tutorials/user/01-first-launch-04.png index 05fa35dc..f6a0ec33 100644 Binary files a/docs/static/screenshots/tutorials/user/01-first-launch-04.png and b/docs/static/screenshots/tutorials/user/01-first-launch-04.png differ diff --git a/docs/static/screenshots/tutorials/user/02-create-from-template-01.png b/docs/static/screenshots/tutorials/user/02-create-from-template-01.png index 249c824a..7b37b07f 100644 Binary files a/docs/static/screenshots/tutorials/user/02-create-from-template-01.png and b/docs/static/screenshots/tutorials/user/02-create-from-template-01.png differ diff --git a/docs/static/screenshots/tutorials/user/02-create-from-template-02.png b/docs/static/screenshots/tutorials/user/02-create-from-template-02.png index 249c824a..7b37b07f 100644 Binary files a/docs/static/screenshots/tutorials/user/02-create-from-template-02.png and b/docs/static/screenshots/tutorials/user/02-create-from-template-02.png differ diff --git a/docs/static/screenshots/tutorials/user/02-create-from-template-03.png b/docs/static/screenshots/tutorials/user/02-create-from-template-03.png index eb85d7cc..a58bbb32 100644 Binary files a/docs/static/screenshots/tutorials/user/02-create-from-template-03.png and b/docs/static/screenshots/tutorials/user/02-create-from-template-03.png differ diff --git a/docs/static/screenshots/tutorials/user/02-create-from-template-04.png b/docs/static/screenshots/tutorials/user/02-create-from-template-04.png index 05fa35dc..f6a0ec33 100644 Binary files a/docs/static/screenshots/tutorials/user/02-create-from-template-04.png and b/docs/static/screenshots/tutorials/user/02-create-from-template-04.png differ diff --git a/docs/static/screenshots/tutorials/user/02-create-from-template-05.png b/docs/static/screenshots/tutorials/user/02-create-from-template-05.png index 05fa35dc..f6a0ec33 100644 Binary files a/docs/static/screenshots/tutorials/user/02-create-from-template-05.png and b/docs/static/screenshots/tutorials/user/02-create-from-template-05.png differ diff --git a/docs/static/screenshots/tutorials/user/03-design-schema-01.png b/docs/static/screenshots/tutorials/user/03-design-schema-01.png index a5fcb7bc..70ac277a 100644 Binary files a/docs/static/screenshots/tutorials/user/03-design-schema-01.png and b/docs/static/screenshots/tutorials/user/03-design-schema-01.png differ diff --git a/docs/static/screenshots/tutorials/user/03-design-schema-02.png b/docs/static/screenshots/tutorials/user/03-design-schema-02.png index 64779121..db0a019d 100644 Binary files a/docs/static/screenshots/tutorials/user/03-design-schema-02.png and b/docs/static/screenshots/tutorials/user/03-design-schema-02.png differ diff --git a/docs/static/screenshots/tutorials/user/03-design-schema-03.png b/docs/static/screenshots/tutorials/user/03-design-schema-03.png index 8127b126..dee363eb 100644 Binary files a/docs/static/screenshots/tutorials/user/03-design-schema-03.png and b/docs/static/screenshots/tutorials/user/03-design-schema-03.png differ diff --git a/docs/static/screenshots/tutorials/user/03-design-schema-04.png b/docs/static/screenshots/tutorials/user/03-design-schema-04.png index e68c03de..c137d34a 100644 Binary files a/docs/static/screenshots/tutorials/user/03-design-schema-04.png and b/docs/static/screenshots/tutorials/user/03-design-schema-04.png differ diff --git a/docs/static/screenshots/tutorials/user/03-design-schema-05.png b/docs/static/screenshots/tutorials/user/03-design-schema-05.png index e68c03de..c137d34a 100644 Binary files a/docs/static/screenshots/tutorials/user/03-design-schema-05.png and b/docs/static/screenshots/tutorials/user/03-design-schema-05.png differ diff --git a/docs/static/screenshots/tutorials/user/04-design-page-01.png b/docs/static/screenshots/tutorials/user/04-design-page-01.png index 72f0b9a0..4e33a743 100644 Binary files a/docs/static/screenshots/tutorials/user/04-design-page-01.png and b/docs/static/screenshots/tutorials/user/04-design-page-01.png differ diff --git a/docs/static/screenshots/tutorials/user/04-design-page-02.png b/docs/static/screenshots/tutorials/user/04-design-page-02.png index 72f0b9a0..4e33a743 100644 Binary files a/docs/static/screenshots/tutorials/user/04-design-page-02.png and b/docs/static/screenshots/tutorials/user/04-design-page-02.png differ diff --git a/docs/static/screenshots/tutorials/user/04-design-page-03.png b/docs/static/screenshots/tutorials/user/04-design-page-03.png index 72f0b9a0..4e33a743 100644 Binary files a/docs/static/screenshots/tutorials/user/04-design-page-03.png and b/docs/static/screenshots/tutorials/user/04-design-page-03.png differ diff --git a/docs/static/screenshots/tutorials/user/04-design-page-04.png b/docs/static/screenshots/tutorials/user/04-design-page-04.png index 72f0b9a0..4e33a743 100644 Binary files a/docs/static/screenshots/tutorials/user/04-design-page-04.png and b/docs/static/screenshots/tutorials/user/04-design-page-04.png differ diff --git a/docs/static/screenshots/tutorials/user/04-design-page-05.png b/docs/static/screenshots/tutorials/user/04-design-page-05.png index 72f0b9a0..4e33a743 100644 Binary files a/docs/static/screenshots/tutorials/user/04-design-page-05.png and b/docs/static/screenshots/tutorials/user/04-design-page-05.png differ diff --git a/docs/static/screenshots/tutorials/user/05-connect-data-01.png b/docs/static/screenshots/tutorials/user/05-connect-data-01.png index 451094aa..4e33a743 100644 Binary files a/docs/static/screenshots/tutorials/user/05-connect-data-01.png and b/docs/static/screenshots/tutorials/user/05-connect-data-01.png differ diff --git a/docs/static/screenshots/tutorials/user/05-connect-data-02.png b/docs/static/screenshots/tutorials/user/05-connect-data-02.png index 451094aa..4e33a743 100644 Binary files a/docs/static/screenshots/tutorials/user/05-connect-data-02.png and b/docs/static/screenshots/tutorials/user/05-connect-data-02.png differ diff --git a/docs/static/screenshots/tutorials/user/05-connect-data-03.png b/docs/static/screenshots/tutorials/user/05-connect-data-03.png index 451094aa..4e33a743 100644 Binary files a/docs/static/screenshots/tutorials/user/05-connect-data-03.png and b/docs/static/screenshots/tutorials/user/05-connect-data-03.png differ diff --git a/docs/static/screenshots/tutorials/user/05-connect-data-04.png b/docs/static/screenshots/tutorials/user/05-connect-data-04.png index 451094aa..4e33a743 100644 Binary files a/docs/static/screenshots/tutorials/user/05-connect-data-04.png and b/docs/static/screenshots/tutorials/user/05-connect-data-04.png differ diff --git a/docs/static/screenshots/tutorials/user/05-connect-data-05.png b/docs/static/screenshots/tutorials/user/05-connect-data-05.png index 451094aa..4e33a743 100644 Binary files a/docs/static/screenshots/tutorials/user/05-connect-data-05.png and b/docs/static/screenshots/tutorials/user/05-connect-data-05.png differ diff --git a/docs/static/screenshots/tutorials/user/06-preview-app-01.png b/docs/static/screenshots/tutorials/user/06-preview-app-01.png index d7ea1b67..296ef4bf 100644 Binary files a/docs/static/screenshots/tutorials/user/06-preview-app-01.png and b/docs/static/screenshots/tutorials/user/06-preview-app-01.png differ diff --git a/docs/static/screenshots/tutorials/user/06-preview-app-02.png b/docs/static/screenshots/tutorials/user/06-preview-app-02.png index d7ea1b67..296ef4bf 100644 Binary files a/docs/static/screenshots/tutorials/user/06-preview-app-02.png and b/docs/static/screenshots/tutorials/user/06-preview-app-02.png differ diff --git a/docs/static/screenshots/tutorials/user/06-preview-app-03.png b/docs/static/screenshots/tutorials/user/06-preview-app-03.png index d7ea1b67..296ef4bf 100644 Binary files a/docs/static/screenshots/tutorials/user/06-preview-app-03.png and b/docs/static/screenshots/tutorials/user/06-preview-app-03.png differ diff --git a/docs/static/screenshots/tutorials/user/06-preview-app-04.png b/docs/static/screenshots/tutorials/user/06-preview-app-04.png index d7ea1b67..296ef4bf 100644 Binary files a/docs/static/screenshots/tutorials/user/06-preview-app-04.png and b/docs/static/screenshots/tutorials/user/06-preview-app-04.png differ diff --git a/docs/static/screenshots/tutorials/user/06-preview-app-05.png b/docs/static/screenshots/tutorials/user/06-preview-app-05.png index 8c4e88ef..e032f4f9 100644 Binary files a/docs/static/screenshots/tutorials/user/06-preview-app-05.png and b/docs/static/screenshots/tutorials/user/06-preview-app-05.png differ diff --git a/docs/static/screenshots/tutorials/user/07-version-snapshots-01.png b/docs/static/screenshots/tutorials/user/07-version-snapshots-01.png index 67b5f4e8..f6a0ec33 100644 Binary files a/docs/static/screenshots/tutorials/user/07-version-snapshots-01.png and b/docs/static/screenshots/tutorials/user/07-version-snapshots-01.png differ diff --git a/docs/static/screenshots/tutorials/user/07-version-snapshots-02.png b/docs/static/screenshots/tutorials/user/07-version-snapshots-02.png index 67b5f4e8..f6a0ec33 100644 Binary files a/docs/static/screenshots/tutorials/user/07-version-snapshots-02.png and b/docs/static/screenshots/tutorials/user/07-version-snapshots-02.png differ diff --git a/docs/static/screenshots/tutorials/user/07-version-snapshots-03.png b/docs/static/screenshots/tutorials/user/07-version-snapshots-03.png index 67b5f4e8..f6a0ec33 100644 Binary files a/docs/static/screenshots/tutorials/user/07-version-snapshots-03.png and b/docs/static/screenshots/tutorials/user/07-version-snapshots-03.png differ diff --git a/docs/static/screenshots/tutorials/user/07-version-snapshots-04.png b/docs/static/screenshots/tutorials/user/07-version-snapshots-04.png index 67b5f4e8..f6a0ec33 100644 Binary files a/docs/static/screenshots/tutorials/user/07-version-snapshots-04.png and b/docs/static/screenshots/tutorials/user/07-version-snapshots-04.png differ diff --git a/docs/static/screenshots/tutorials/user/07-version-snapshots-05.png b/docs/static/screenshots/tutorials/user/07-version-snapshots-05.png index 67b5f4e8..f6a0ec33 100644 Binary files a/docs/static/screenshots/tutorials/user/07-version-snapshots-05.png and b/docs/static/screenshots/tutorials/user/07-version-snapshots-05.png differ diff --git a/lib/Mcp/OpenBuiltToolProvider.php b/lib/Mcp/OpenBuiltToolProvider.php index 282d2754..f648e536 100644 --- a/lib/Mcp/OpenBuiltToolProvider.php +++ b/lib/Mcp/OpenBuiltToolProvider.php @@ -3,25 +3,13 @@ /** * OpenBuilt MCP Tool Provider * - * Per-app implementation of OCA\OpenRegister\Mcp\IMcpToolProvider (hydra ADR-034 - * AI Chat Companion + ADR-035 MCP tool surface). Exposes two read-only MVP tools - * so the AI Chat Companion can surface OpenBuilt's virtual-app catalogue to an LLM: - * listing the virtual apps in the caller's organisation and reading a single - * published app's manifest by slug. - * - * @category Mcp - * @package OCA\OpenBuilt\Mcp - * - * @author Conduction Development Team - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * Per-app implementation of OCA\OpenRegister\Mcp\IMcpToolProvider. Exposes the + * full OpenBuilt authoring surface to an LLM via MCP: list/read apps, create + * new apps, promote versions, and mutate a draft version's manifest (pages, + * widgets, menu items) and per-version schemas. * * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 - * - * @version GIT: - * - * @link https://conduction.nl */ declare(strict_types=1); @@ -37,72 +25,53 @@ /** * OpenBuilt MCP Tool Provider. * - * Implements IMcpToolProvider (from openregister PR #1466, - * change ai-chat-companion-orchestrator) exposing two read-only tools to the - * AI Chat Companion. The full catalogue is always returned by getTools(); - * per-object authorisation runs inside invokeTool(). + * Read tools: + * - openbuilt.listApps + * - openbuilt.getAppManifest + * + * Write tools (lifecycle): + * - openbuilt.createApp + * - openbuilt.promoteVersion * - * Auth design (OWASP A01:2021 / ADR-005): - * - Per-object authorisation runs inside invokeTool(), AFTER argument validation - * but BEFORE business logic. The helper invoked MUST actually run. - * - requireAuthenticatedUser() returns string|null — it does NOT return a - * non-empty value unconditionally and is NOT wrapped in catch(\Throwable). - * - Object-level scoping (organisation) is enforced by OpenRegister's - * ObjectService multitenancy filter on every query; admin (IGroupManager::isAdmin) - * is exposed via isAdmin() for symmetry with sibling providers and future ACLs. + * Write tools (authoring against the draft version's manifest): + * - openbuilt.upsertSchema (per-version OR schema) + * - openbuilt.upsertPage (manifest.pages slot) + * - openbuilt.addWidget (page.config.widgets append) + * - openbuilt.upsertMenuItem (manifest.menu slot) * - * Both tools are read-only; there are no state-changing tools in this MVP. + * Authoring tools default to the `development` version so a misfired tool + * call cannot mutate production. To promote the change use promoteVersion. */ class OpenBuiltToolProvider implements IMcpToolProvider { - /** - * Maximum number of items (and source descriptors) per list result. - * - * @var int - */ private const ITEMS_CAP = 20; - /** - * The OpenRegister register slug OpenBuilt stores its objects in. - * - * @var string - */ private const REGISTER_SLUG = 'openbuilt'; - /** - * Allowed values for the listApps statusFilter argument. - * - * @var array - */ private const APP_STATUSES = ['any', 'draft', 'published', 'archived']; + private const CREATE_PRESETS = ['single', 'dev-prod', 'dev-staging-prod']; + + private const PROMOTE_STRATEGIES = ['empty-start', 'start-with-source-data', 'migrate-existing-data']; + + private const PAGE_TYPES = ['dashboard', 'index', 'detail', 'form']; + /** * Tool catalogue. * - * Hard-coded as a constant so unit tests can assert it as a fixture. - * * @var array> */ private const TOOL_DESCRIPTORS = [ [ 'id' => 'openbuilt.listApps', 'name' => 'List virtual apps', - 'description' => 'List the virtual apps built with OpenBuilt in your organisation, with slug, name, status and version.', + 'description' => 'List the virtual apps built with OpenBuilt in your organisation.', 'inputSchema' => [ 'type' => 'object', 'properties' => [ - 'limit' => [ - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => 50, - 'default' => 20, - ], - 'statusFilter' => [ - 'type' => 'string', - 'enum' => ['any', 'draft', 'published', 'archived'], - 'default' => 'any', - ], + 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 20], + 'statusFilter' => ['type' => 'string', 'enum' => ['any', 'draft', 'published', 'archived'], 'default' => 'any'], ], 'required' => [], ], @@ -110,32 +79,116 @@ class OpenBuiltToolProvider implements IMcpToolProvider [ 'id' => 'openbuilt.getAppManifest', 'name' => 'Get virtual app manifest', - 'description' => 'Fetch the runtime manifest blob for one published virtual app, addressed by its kebab-case slug.', + 'description' => 'Fetch the runtime manifest blob for one published virtual app by slug.', 'inputSchema' => [ 'type' => 'object', 'properties' => [ - 'slug' => [ - 'type' => 'string', - 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', - 'minLength' => 2, - 'maxLength' => 48, - ], + 'slug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'minLength' => 2, 'maxLength' => 48], ], 'required' => ['slug'], ], ], + [ + 'id' => 'openbuilt.createApp', + 'name' => 'Create a new virtual app', + 'description' => 'Create a new OpenBuilt virtual app with an initial draft ApplicationVersion. Preset chooses the version chain: "single", "dev-prod" or "dev-staging-prod".', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'slug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'minLength' => 2, 'maxLength' => 48], + 'name' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 80], + 'description' => ['type' => 'string', 'maxLength' => 500], + 'preset' => ['type' => 'string', 'enum' => ['single', 'dev-prod', 'dev-staging-prod'], 'default' => 'dev-prod'], + ], + 'required' => ['slug', 'name'], + ], + ], + [ + 'id' => 'openbuilt.promoteVersion', + 'name' => 'Promote a virtual app version', + 'description' => 'Promote a virtual app from one version (e.g. development) to the next (e.g. production). Strategy "empty-start" (default, safest) leaves the target empty.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'appSlug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'minLength' => 2, 'maxLength' => 48], + 'sourceVersionSlug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'minLength' => 2, 'maxLength' => 48], + 'strategy' => ['type' => 'string', 'enum' => ['empty-start', 'start-with-source-data', 'migrate-existing-data'], 'default' => 'empty-start'], + ], + 'required' => ['appSlug', 'sourceVersionSlug'], + ], + ], + [ + 'id' => 'openbuilt.upsertSchema', + 'name' => 'Create or update a schema in a virtual app', + 'description' => 'Create or update a JSON Schema in the given app version\'s per-version OR register. Slug is automatically namespaced with appSlug+versionSlug. Properties is a JSON Schema property map; required is an array of property names. Defaults versionSlug to "development".', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'appSlug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'minLength' => 2, 'maxLength' => 48], + 'versionSlug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'default' => 'development'], + 'slug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'minLength' => 2, 'maxLength' => 48], + 'title' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 80], + 'description' => ['type' => 'string', 'maxLength' => 500], + 'properties' => ['type' => 'object'], + 'required' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['appSlug', 'slug', 'title', 'properties'], + ], + ], + [ + 'id' => 'openbuilt.upsertPage', + 'name' => 'Create or update a page in a virtual app', + 'description' => 'Create or update a page in the draft manifest. pageId is the unique key; if it exists it is replaced. Type is one of dashboard, index, detail, form. config is page-type-specific (e.g. {register, schema, columns} for index pages, {widgets, layout} for dashboards). Defaults versionSlug to "development".', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'appSlug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'minLength' => 2, 'maxLength' => 48], + 'versionSlug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'default' => 'development'], + 'pageId' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64], + 'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 80], + 'type' => ['type' => 'string', 'enum' => ['dashboard', 'index', 'detail', 'form']], + 'route' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200], + 'config' => ['type' => 'object'], + ], + 'required' => ['appSlug', 'pageId', 'title', 'type', 'route'], + ], + ], + [ + 'id' => 'openbuilt.addWidget', + 'name' => 'Add a widget to a page', + 'description' => 'Append a widget to a page\'s config.widgets array in the draft manifest. widgetType is e.g. "stat-counter", "chart", "list". widgetConfig is widget-type-specific. Defaults versionSlug to "development".', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'appSlug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'minLength' => 2, 'maxLength' => 48], + 'versionSlug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'default' => 'development'], + 'pageId' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64], + 'widgetType' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 48], + 'widgetConfig' => ['type' => 'object'], + ], + 'required' => ['appSlug', 'pageId', 'widgetType'], + ], + ], + [ + 'id' => 'openbuilt.upsertMenuItem', + 'name' => 'Create or update a menu item', + 'description' => 'Create or update a top-level menu item in the draft manifest. id is the unique key; if it exists it is replaced. route should match a page id. order controls sort. icon is an MDI/standard icon name. Defaults versionSlug to "development".', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'appSlug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'minLength' => 2, 'maxLength' => 48], + 'versionSlug' => ['type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9-]*[a-z0-9]$', 'default' => 'development'], + 'id' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64], + 'label' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 80], + 'icon' => ['type' => 'string', 'maxLength' => 80], + 'route' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200], + 'order' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 999], + ], + 'required' => ['appSlug', 'id', 'label', 'route'], + ], + ], ]; - /** - * Constructor for OpenBuiltToolProvider. - * - * @param IUserSession $userSession The current user session - * @param IGroupManager $groupManager The group manager (for admin checks) - * @param ContainerInterface $container The DI container (for ObjectService) - * @param LoggerInterface $logger The PSR-3 logger - * - * @return void - */ public function __construct( private readonly IUserSession $userSession, private readonly IGroupManager $groupManager, @@ -144,11 +197,6 @@ public function __construct( ) { }//end __construct() - /** - * Returns the app ID that namespaces every tool id. - * - * @return string "openbuilt" - */ public function getAppId(): string { return 'openbuilt'; @@ -156,11 +204,6 @@ public function getAppId(): string }//end getAppId() /** - * Returns the full tool catalogue (2 tools, always). - * - * The full catalogue is always returned regardless of caller permissions. - * Per-object authorisation runs in invokeTool(). - * * @return array> */ public function getTools(): array @@ -170,23 +213,22 @@ public function getTools(): array }//end getTools() /** - * Dispatch a tool call by id. - * - * Argument validation runs BEFORE authorisation (cheap before expensive), - * which runs BEFORE business logic. Unknown tool ids return a structured - * error; no exception is thrown. - * - * @param string $toolId The tool id (e.g. "openbuilt.listApps") - * @param array $arguments Tool arguments from the LLM call + * @param array $arguments * * @return array */ public function invokeTool(string $toolId, array $arguments): array { return match ($toolId) { - 'openbuilt.listApps' => $this->handleListApps(args: $arguments), - 'openbuilt.getAppManifest' => $this->handleGetAppManifest(args: $arguments), - default => $this->errorResult( + 'openbuilt.listApps' => $this->handleListApps(args: $arguments), + 'openbuilt.getAppManifest' => $this->handleGetAppManifest(args: $arguments), + 'openbuilt.createApp' => $this->handleCreateApp(args: $arguments), + 'openbuilt.promoteVersion' => $this->handlePromoteVersion(args: $arguments), + 'openbuilt.upsertSchema' => $this->handleUpsertSchema(args: $arguments), + 'openbuilt.upsertPage' => $this->handleUpsertPage(args: $arguments), + 'openbuilt.addWidget' => $this->handleAddWidget(args: $arguments), + 'openbuilt.upsertMenuItem' => $this->handleUpsertMenuItem(args: $arguments), + default => $this->errorResult( error: 'unknown_tool', message: "Unknown tool id '{$toolId}'. Available tools: " .implode(separator: ', ', array: array_column(array: self::TOOL_DESCRIPTORS, column_key: 'id')).'.', @@ -196,17 +238,11 @@ public function invokeTool(string $toolId, array $arguments): array }//end invokeTool() // ========================================================================= - // Private tool handlers + // Read handlers // ========================================================================= /** - * Handle openbuilt.listApps. - * - * Returns the virtual apps in the caller's organisation. Scoping is enforced - * by OpenRegister's ObjectService multitenancy filter; the caller must be - * authenticated. - * - * @param array $args Tool arguments + * @param array $args * * @return array */ @@ -224,12 +260,12 @@ private function handleListApps(array $args): array try { $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); - $query = ['@self' => ['register' => self::REGISTER_SLUG, 'schema' => 'application']]; + $filters = []; if ($validation['statusFilter'] !== 'any') { - $query['status'] = $validation['statusFilter']; + $filters['status'] = $validation['statusFilter']; } - $rawApps = $objectService->searchObjects(query: $query); + $rawApps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', $filters); if (is_array(value: $rawApps) === false) { $rawApps = []; } @@ -247,18 +283,13 @@ private function handleListApps(array $args): array return ['success' => true, 'apps' => $apps, 'sources' => $sources]; } catch (\Throwable $e) { $this->logger->error('OpenBuilt MCP: listApps failed', ['exception' => $e->getMessage()]); - return $this->errorResult(error: 'internal_error', message: 'Failed to retrieve virtual apps. See server log for details.'); + return $this->errorResult(error: 'internal_error', message: 'Failed to retrieve virtual apps.'); }//end try }//end handleListApps() /** - * Handle openbuilt.getAppManifest. - * - * Resolves a slug → BuiltAppRoute → applicationUuid → Application → manifest, - * mirroring the lookup performed by ApplicationsController::getManifest(). - * - * @param array $args Tool arguments + * @param array $args * * @return array */ @@ -270,7 +301,7 @@ private function handleGetAppManifest(array $args): array } if ($this->isValidSlug(candidate: (string) $slug) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid slug '{$slug}'. Expected kebab-case, 2-48 chars."); + return $this->errorResult(error: 'invalid_arguments', message: "Invalid slug '{$slug}'."); } if ($this->requireAuthenticatedUser() === null) { @@ -279,8 +310,7 @@ private function handleGetAppManifest(array $args): array try { $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); - - $resolved = $this->resolveApplicationBySlug(objectService: $objectService, slug: (string) $slug); + $resolved = $this->resolveApplicationBySlug(objectService: $objectService, slug: (string) $slug); if (isset($resolved['error']) === true) { return $this->errorResult(error: $resolved['error'], message: $resolved['message']); } @@ -292,31 +322,591 @@ private function handleGetAppManifest(array $args): array } $name = (string) ($application['name'] ?? $slug); - return [ 'success' => true, 'slug' => (string) $slug, 'name' => $name, - 'version' => (string) ($application['version'] ?? ''), - 'status' => (string) ($application['status'] ?? ''), 'manifest' => $manifest, 'sources' => [$this->sourceDescriptor(uuid: $this->extractUuid(item: $application), slug: (string) $slug, label: $name)], ]; } catch (\Throwable $e) { $this->logger->error('OpenBuilt MCP: getAppManifest failed', ['slug' => $slug, 'exception' => $e->getMessage()]); - return $this->errorResult(error: 'internal_error', message: 'Failed to resolve manifest. See server log for details.'); + return $this->errorResult(error: 'internal_error', message: 'Failed to resolve manifest.'); }//end try }//end handleGetAppManifest() // ========================================================================= - // Private helpers + // Lifecycle handlers + // ========================================================================= + + /** + * @param array $args + * + * @return array + */ + private function handleCreateApp(array $args): array + { + $slug = (string) ($args['slug'] ?? ''); + $name = (string) ($args['name'] ?? ''); + $description = (string) ($args['description'] ?? ''); + $preset = (string) ($args['preset'] ?? 'dev-prod'); + + if ($slug === '' || $this->isValidSlug(candidate: $slug) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid slug '{$slug}'."); + } + + if ($name === '' || strlen($name) < 2 || strlen($name) > 80) { + return $this->errorResult(error: 'invalid_arguments', message: 'Name must be between 2 and 80 characters.'); + } + + if (in_array(needle: $preset, haystack: self::CREATE_PRESETS, strict: true) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid preset '{$preset}'."); + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to create a virtual app.'); + } + + try { + $creationService = $this->container->get('OCA\OpenBuilt\Service\ApplicationCreationService'); + $appUuid = $creationService->createApplication( + [ + 'slug' => $slug, + 'name' => $name, + 'description' => $description, + 'preset' => $preset, + ] + ); + + return [ + 'success' => true, + 'created' => true, + 'app' => ['uuid' => $appUuid, 'slug' => $slug, 'name' => $name, 'preset' => $preset], + 'sources' => [$this->sourceDescriptor(uuid: $appUuid, slug: $slug, label: $name)], + ]; + } catch (\Throwable $e) { + $this->logger->error('OpenBuilt MCP: createApp failed', ['slug' => $slug, 'exception' => $e->getMessage()]); + return $this->errorResult(error: 'create_failed', message: 'Failed to create virtual app: '.$e->getMessage()); + }//end try + + }//end handleCreateApp() + + /** + * @param array $args + * + * @return array + */ + private function handlePromoteVersion(array $args): array + { + $appSlug = (string) ($args['appSlug'] ?? ''); + $sourceVersionSlug = (string) ($args['sourceVersionSlug'] ?? ''); + $strategy = (string) ($args['strategy'] ?? 'empty-start'); + + if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid appSlug '{$appSlug}'."); + } + + if ($sourceVersionSlug === '' || $this->isValidSlug(candidate: $sourceVersionSlug) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid sourceVersionSlug '{$sourceVersionSlug}'."); + } + + if (in_array(needle: $strategy, haystack: self::PROMOTE_STRATEGIES, strict: true) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid strategy '{$strategy}'."); + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to promote a virtual app version.'); + } + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + + $loaded = $this->loadVersion($objectService, $appSlug, $sourceVersionSlug); + if (isset($loaded['error']) === true) { + return $this->errorResult(error: $loaded['error'], message: $loaded['message']); + } + + $source = $loaded['version']; + if (($source['promotesTo'] ?? null) === null || $source['promotesTo'] === '') { + return $this->errorResult(error: 'no_promote_target', message: "Version '{$sourceVersionSlug}' has no downstream target."); + } + + $promotionService = $this->container->get('OCA\OpenBuilt\Service\VersionPromotionService'); + $updatedTarget = $promotionService->promote($source, $strategy); + $targetUuid = $this->extractUuid(item: $updatedTarget); + + return [ + 'success' => true, + 'promoted' => true, + 'strategy' => $strategy, + 'from' => ['uuid' => $this->extractUuid(item: $source), 'slug' => $sourceVersionSlug], + 'to' => [ + 'uuid' => $targetUuid, + 'slug' => (string) ($updatedTarget['slug'] ?? ''), + 'status' => (string) ($updatedTarget['status'] ?? ''), + ], + 'sources' => [$this->sourceDescriptor(uuid: $loaded['appUuid'], slug: $appSlug, label: $loaded['appName'])], + ]; + } catch (\Throwable $e) { + $this->logger->error('OpenBuilt MCP: promoteVersion failed', ['appSlug' => $appSlug, 'source' => $sourceVersionSlug, 'exception' => $e->getMessage()]); + return $this->errorResult(error: 'promote_failed', message: 'Failed to promote version: '.$e->getMessage()); + }//end try + + }//end handlePromoteVersion() + + // ========================================================================= + // Authoring handlers // ========================================================================= /** - * Validate the listApps arguments. + * @param array $args * - * @param array $args Tool arguments. + * @return array + */ + private function handleUpsertSchema(array $args): array + { + $appSlug = (string) ($args['appSlug'] ?? ''); + $versionSlug = (string) ($args['versionSlug'] ?? 'development'); + $rawSlug = (string) ($args['slug'] ?? ''); + $title = (string) ($args['title'] ?? ''); + $description = (string) ($args['description'] ?? ''); + $properties = $args['properties'] ?? []; + $required = $args['required'] ?? []; + + if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid appSlug '{$appSlug}'."); + } + + if ($this->isValidSlug(candidate: $versionSlug) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid versionSlug '{$versionSlug}'."); + } + + if ($rawSlug === '' || $this->isValidSlug(candidate: $rawSlug) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid schema slug '{$rawSlug}'."); + } + + if ($title === '') { + return $this->errorResult(error: 'invalid_arguments', message: 'title is required.'); + } + + if (is_array($properties) === false || $properties === []) { + return $this->errorResult(error: 'invalid_arguments', message: 'properties must be a non-empty object of JSON-Schema property definitions.'); + } + + if (is_array($required) === false) { + $required = []; + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to author schemas.'); + } + + try { + $schemaMapper = $this->container->get('OCA\OpenRegister\Db\SchemaMapper'); + $registerMapper = $this->container->get('OCA\OpenRegister\Db\RegisterMapper'); + + $registerSlug = 'openbuilt-'.$appSlug.'-'.$versionSlug; + $namespacedSlug = $appSlug.'-'.$versionSlug.'-'.$rawSlug; + + $blob = [ + 'slug' => $namespacedSlug, + 'title' => $title, + 'description' => $description, + 'type' => 'object', + 'required' => array_values(array_filter((array) $required, 'is_string')), + 'properties' => (array) $properties, + ]; + + // findBySlug returns Schema[] (may be empty). Take the first hit. + $existing = null; + try { + $matches = $schemaMapper->findBySlug($namespacedSlug); + if (is_array($matches) === true && $matches !== []) { + $existing = $matches[0]; + } + } catch (\Throwable $_e) { + $existing = null; + } + + if ($existing !== null) { + $schema = $schemaMapper->updateFromArray($existing->getId(), $blob); + $action = 'updated'; + } else { + $schema = $schemaMapper->createFromArray($blob); + $action = 'created'; + + // Attach the new schema to the per-version register. + try { + $register = $registerMapper->find($registerSlug, _multitenancy: false); + $current = $register->getSchemas(); + if (is_array($current) === false) { + $current = []; + } + + $register->setSchemas(array_values(array_unique(array_merge($current, [$schema->getId()])))); + $registerMapper->update($register); + } catch (\Throwable $e) { + $this->logger->warning('OpenBuilt MCP: upsertSchema attach-to-register failed', ['register' => $registerSlug, 'exception' => $e->getMessage()]); + } + }//end if + + return [ + 'success' => true, + 'action' => $action, + 'schema' => [ + 'id' => $schema->getId(), + 'slug' => $namespacedSlug, + 'shortSlug' => $rawSlug, + 'title' => $title, + 'register' => $registerSlug, + ], + ]; + } catch (\Throwable $e) { + $this->logger->error('OpenBuilt MCP: upsertSchema failed', ['appSlug' => $appSlug, 'slug' => $rawSlug, 'exception' => $e->getMessage()]); + return $this->errorResult(error: 'upsert_failed', message: 'Failed to upsert schema: '.$e->getMessage()); + }//end try + + }//end handleUpsertSchema() + + /** + * @param array $args + * + * @return array + */ + private function handleUpsertPage(array $args): array + { + $appSlug = (string) ($args['appSlug'] ?? ''); + $versionSlug = (string) ($args['versionSlug'] ?? 'development'); + $pageId = (string) ($args['pageId'] ?? ''); + $title = (string) ($args['title'] ?? ''); + $type = (string) ($args['type'] ?? ''); + $route = (string) ($args['route'] ?? ''); + $config = $args['config'] ?? []; + + if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid appSlug '{$appSlug}'."); + } + + if ($pageId === '') { + return $this->errorResult(error: 'invalid_arguments', message: 'pageId is required.'); + } + + if ($title === '') { + return $this->errorResult(error: 'invalid_arguments', message: 'title is required.'); + } + + if (in_array(needle: $type, haystack: self::PAGE_TYPES, strict: true) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid page type '{$type}'."); + } + + if ($route === '') { + return $this->errorResult(error: 'invalid_arguments', message: 'route is required.'); + } + + if (is_array($config) === false) { + $config = []; + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to author pages.'); + } + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + + $loaded = $this->loadVersion($objectService, $appSlug, $versionSlug); + if (isset($loaded['error']) === true) { + return $this->errorResult(error: $loaded['error'], message: $loaded['message']); + } + + $version = $loaded['version']; + $manifest = (array) ($version['manifest'] ?? []); + $pages = (array) ($manifest['pages'] ?? []); + + $newPage = [ + 'id' => $pageId, + 'route' => $route, + 'type' => $type, + 'title' => $title, + 'config' => $config, + ]; + + // Case-insensitive id lookup so the LLM doesn't have to remember + // exact casing ("dashboard" must still find "Dashboard"). + $replaced = false; + $pageIdLc = strtolower($pageId); + foreach ($pages as $i => $existing) { + if (is_array($existing) === true && strtolower((string) ($existing['id'] ?? '')) === $pageIdLc) { + $pages[$i] = $newPage; + $replaced = true; + break; + } + } + + if ($replaced === false) { + $pages[] = $newPage; + } + + $manifest['pages'] = array_values($pages); + $saved = $this->saveVersionManifest($objectService, $version, $manifest); + + return [ + 'success' => true, + 'action' => $replaced ? 'updated' : 'created', + 'page' => $newPage, + 'pageCount' => count($pages), + 'version' => [ + 'uuid' => $this->extractUuid(item: $saved), + 'slug' => (string) ($saved['slug'] ?? $versionSlug), + ], + ]; + } catch (\Throwable $e) { + $this->logger->error('OpenBuilt MCP: upsertPage failed', ['appSlug' => $appSlug, 'pageId' => $pageId, 'exception' => $e->getMessage()]); + return $this->errorResult(error: 'upsert_failed', message: 'Failed to upsert page: '.$e->getMessage()); + }//end try + + }//end handleUpsertPage() + + /** + * @param array $args + * + * @return array + */ + private function handleAddWidget(array $args): array + { + $appSlug = (string) ($args['appSlug'] ?? ''); + $versionSlug = (string) ($args['versionSlug'] ?? 'development'); + $pageId = (string) ($args['pageId'] ?? ''); + $widgetType = (string) ($args['widgetType'] ?? ''); + $widgetConfig = $args['widgetConfig'] ?? []; + + if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid appSlug '{$appSlug}'."); + } + + if ($pageId === '') { + return $this->errorResult(error: 'invalid_arguments', message: 'pageId is required.'); + } + + if ($widgetType === '') { + return $this->errorResult(error: 'invalid_arguments', message: 'widgetType is required.'); + } + + if (is_array($widgetConfig) === false) { + $widgetConfig = []; + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to add widgets.'); + } + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + + $loaded = $this->loadVersion($objectService, $appSlug, $versionSlug); + if (isset($loaded['error']) === true) { + return $this->errorResult(error: $loaded['error'], message: $loaded['message']); + } + + $version = $loaded['version']; + $manifest = (array) ($version['manifest'] ?? []); + $pages = (array) ($manifest['pages'] ?? []); + + // Case-insensitive lookup (see upsertPage rationale). + $foundIdx = null; + $pageIdLc = strtolower($pageId); + foreach ($pages as $i => $existing) { + if (is_array($existing) === true && strtolower((string) ($existing['id'] ?? '')) === $pageIdLc) { + $foundIdx = $i; + break; + } + } + + if ($foundIdx === null) { + return $this->errorResult(error: 'not_found', message: "Page '{$pageId}' not found in manifest."); + } + + $page = $pages[$foundIdx]; + $pageConfig = (array) ($page['config'] ?? []); + $widgets = (array) ($pageConfig['widgets'] ?? []); + $widget = ['type' => $widgetType, 'config' => $widgetConfig]; + $widgets[] = $widget; + $pageConfig['widgets'] = $widgets; + $page['config'] = $pageConfig; + $pages[$foundIdx] = $page; + $manifest['pages'] = array_values($pages); + + $saved = $this->saveVersionManifest($objectService, $version, $manifest); + + return [ + 'success' => true, + 'added' => true, + 'widget' => $widget, + 'pageId' => $pageId, + 'widgetCount' => count($widgets), + 'version' => [ + 'uuid' => $this->extractUuid(item: $saved), + 'slug' => (string) ($saved['slug'] ?? $versionSlug), + ], + ]; + } catch (\Throwable $e) { + $this->logger->error('OpenBuilt MCP: addWidget failed', ['appSlug' => $appSlug, 'pageId' => $pageId, 'exception' => $e->getMessage()]); + return $this->errorResult(error: 'add_failed', message: 'Failed to add widget: '.$e->getMessage()); + }//end try + + }//end handleAddWidget() + + /** + * @param array $args + * + * @return array + */ + private function handleUpsertMenuItem(array $args): array + { + $appSlug = (string) ($args['appSlug'] ?? ''); + $versionSlug = (string) ($args['versionSlug'] ?? 'development'); + $id = (string) ($args['id'] ?? ''); + $label = (string) ($args['label'] ?? ''); + $icon = (string) ($args['icon'] ?? ''); + $route = (string) ($args['route'] ?? ''); + $order = isset($args['order']) ? (int) $args['order'] : 100; + + if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid appSlug '{$appSlug}'."); + } + + if ($id === '') { + return $this->errorResult(error: 'invalid_arguments', message: 'id is required.'); + } + + if ($label === '') { + return $this->errorResult(error: 'invalid_arguments', message: 'label is required.'); + } + + if ($route === '') { + return $this->errorResult(error: 'invalid_arguments', message: 'route is required.'); + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to author menu items.'); + } + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + + $loaded = $this->loadVersion($objectService, $appSlug, $versionSlug); + if (isset($loaded['error']) === true) { + return $this->errorResult(error: $loaded['error'], message: $loaded['message']); + } + + $version = $loaded['version']; + $manifest = (array) ($version['manifest'] ?? []); + $menu = (array) ($manifest['menu'] ?? []); + + $newItem = ['id' => $id, 'label' => $label, 'icon' => $icon, 'route' => $route, 'order' => $order]; + + $replaced = false; + foreach ($menu as $i => $existing) { + if (is_array($existing) === true && (string) ($existing['id'] ?? '') === $id) { + $menu[$i] = $newItem; + $replaced = true; + break; + } + } + + if ($replaced === false) { + $menu[] = $newItem; + } + + $manifest['menu'] = array_values($menu); + $saved = $this->saveVersionManifest($objectService, $version, $manifest); + + return [ + 'success' => true, + 'action' => $replaced ? 'updated' : 'created', + 'menuItem' => $newItem, + 'menuCount' => count($menu), + 'version' => [ + 'uuid' => $this->extractUuid(item: $saved), + 'slug' => (string) ($saved['slug'] ?? $versionSlug), + ], + ]; + } catch (\Throwable $e) { + $this->logger->error('OpenBuilt MCP: upsertMenuItem failed', ['appSlug' => $appSlug, 'id' => $id, 'exception' => $e->getMessage()]); + return $this->errorResult(error: 'upsert_failed', message: 'Failed to upsert menu item: '.$e->getMessage()); + }//end try + + }//end handleUpsertMenuItem() + + // ========================================================================= + // Shared helpers + // ========================================================================= + + /** + * Resolve to {version, appUuid, appName}, or {error,message}. + * + * @return array{version?: array, appUuid?: string, appName?: string, error?: string, message?: string} + */ + private function loadVersion(object $objectService, string $appSlug, string $versionSlug): array + { + $apps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', ['slug' => $appSlug]); + if (is_array($apps) === false || $apps === []) { + return ['error' => 'not_found', 'message' => "No virtual app found for slug '{$appSlug}'."]; + } + + $app = $this->toArray(item: $apps[0]); + $appUuid = $this->extractUuid(item: $app); + + $versions = $objectService->searchObjectsBySlug( + self::REGISTER_SLUG, + 'applicationVersion', + ['application' => $appUuid, 'slug' => $versionSlug] + ); + if (is_array($versions) === false || $versions === []) { + return ['error' => 'not_found', 'message' => "No version '{$versionSlug}' found for app '{$appSlug}'."]; + } + + return [ + 'version' => $this->toArray(item: $versions[0]), + 'appUuid' => $appUuid, + 'appName' => (string) ($app['name'] ?? $appSlug), + ]; + + }//end loadVersion() + + /** + * Save an ApplicationVersion with a new manifest. Retains the full payload so + * OR's `required[]` validator does not reject a partial save. + * + * @param array $version + * @param array $manifest + * + * @return array + */ + private function saveVersionManifest(object $objectService, array $version, array $manifest): array + { + $versionUuid = $this->extractUuid(item: $version); + $payload = $version; + $payload['manifest'] = $manifest; + + // Drop OR-internal `@self` / metadata keys that some readers tack on so + // saveObject treats the input as a clean property bag. + unset($payload['@self'], $payload['id'], $payload['uuid']); + + $saved = $objectService->saveObject( + object: $payload, + register: self::REGISTER_SLUG, + schema: 'applicationVersion', + uuid: $versionUuid, + ); + + return $this->toArray(item: $saved); + + }//end saveVersionManifest() + + /** + * @param array $args * * @return array{limit?: int, statusFilter?: string, error?: string} */ @@ -328,12 +918,12 @@ private function validateListAppsArgs(array $args): array } if ($limit < 1 || $limit > 50) { - return ['error' => "Invalid limit {$limit}. Must be between 1 and 50."]; + return ['error' => "Invalid limit {$limit}."]; } $statusFilter = (string) ($args['statusFilter'] ?? 'any'); if (in_array(needle: $statusFilter, haystack: self::APP_STATUSES, strict: true) === false) { - return ['error' => "Invalid statusFilter '{$statusFilter}'. Allowed: ".implode(separator: ', ', array: self::APP_STATUSES).'.']; + return ['error' => "Invalid statusFilter '{$statusFilter}'."]; } return ['limit' => $limit, 'statusFilter' => $statusFilter]; @@ -341,20 +931,12 @@ private function validateListAppsArgs(array $args): array }//end validateListAppsArgs() /** - * Resolve a slug to its published Application object via the BuiltAppRoute index. - * - * @param object $objectService The OpenRegister ObjectService. - * @param string $slug The virtual-app slug. - * * @return array{application?: array, error?: string, message?: string} */ private function resolveApplicationBySlug(object $objectService, string $slug): array { - $routeResults = $objectService->searchObjects( - query: ['@self' => ['register' => self::REGISTER_SLUG, 'schema' => 'built-app-route'], 'slug' => $slug] - ); - - if (is_array(value: $routeResults) === false || empty($routeResults) === true) { + $routeResults = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'built-app-route', ['slug' => $slug]); + if (is_array($routeResults) === false || $routeResults === []) { return ['error' => 'not_found', 'message' => "No published virtual app found for slug '{$slug}'."]; } @@ -374,9 +956,7 @@ private function resolveApplicationBySlug(object $objectService, string $slug): }//end resolveApplicationBySlug() /** - * Map a raw OpenRegister Application object to the trimmed list shape. - * - * @param mixed $raw Raw item from ObjectService. + * @param mixed $raw * * @return array{uuid: string, slug: string, name: string, description: string, status: string, version: string} */ @@ -384,7 +964,6 @@ private function mapApplication(mixed $raw): array { $app = $this->toArray(item: $raw); $slug = (string) ($app['slug'] ?? ''); - return [ 'uuid' => $this->extractUuid(item: $app), 'slug' => $slug, @@ -397,31 +976,15 @@ private function mapApplication(mixed $raw): array }//end mapApplication() /** - * Build a source descriptor for an OpenBuilt Application. - * - * @param string $uuid The Application UUID. - * @param string $slug The Application slug. - * @param string $label The human-readable label. - * * @return array{type: string, uuid: string, url: string, label: string} */ private function sourceDescriptor(string $uuid, string $slug, string $label): array { - return [ - 'type' => 'openbuilt.application', - 'uuid' => $uuid, - 'url' => $this->buildDeepLink(slug: $slug), - 'label' => $label, - ]; + return ['type' => 'openbuilt.application', 'uuid' => $uuid, 'url' => $this->buildDeepLink(slug: $slug), 'label' => $label]; }//end sourceDescriptor() /** - * Build a structured error envelope. - * - * @param string $error The machine-readable error code. - * @param string $message The human-readable message. - * * @return array{isError: true, error: string, message: string} */ private function errorResult(string $error, string $message): array @@ -430,16 +993,6 @@ private function errorResult(string $error, string $message): array }//end errorResult() - /** - * Resolve the calling user's id, or null when no user is signed in. - * - * Auth design (OWASP A01:2021 / ADR-005): this helper MUST actually run — - * it does not return a non-empty value unconditionally. It is the per-object - * gate for both tools; OpenRegister's ObjectService multitenancy filter then - * scopes results to the caller's organisation. - * - * @return string|null The Nextcloud user id, or null when unauthenticated. - */ private function requireAuthenticatedUser(): ?string { $user = $this->userSession->getUser(); @@ -448,37 +1001,16 @@ private function requireAuthenticatedUser(): ?string } $uid = $user->getUID(); - if ($uid === '') { - return null; - } - - return $uid; + return $uid === '' ? null : $uid; }//end requireAuthenticatedUser() - /** - * Check whether the user is a Nextcloud system administrator. - * - * Exposed for symmetry with sibling MCP providers and future per-app ACLs; - * the MVP tools are read-only and org-scoped, so admin is not required. - * - * @param string $userId The Nextcloud user id. - * - * @return bool True when the user is a system admin. - */ public function isAdmin(string $userId): bool { return $this->groupManager->isAdmin($userId); }//end isAdmin() - /** - * Validate that a string is an OpenBuilt virtual-app slug (kebab-case, 2-48 chars). - * - * @param string $candidate The candidate string to validate. - * - * @return bool True when the string is slug-shaped. - */ private function isValidSlug(string $candidate): bool { if (strlen($candidate) < 2 || strlen($candidate) > 48) { @@ -489,13 +1021,6 @@ private function isValidSlug(string $candidate): bool }//end isValidSlug() - /** - * Build a deep link path for an OpenBuilt virtual app. - * - * @param string $slug The virtual-app slug. - * - * @return string The deep link path, e.g. /apps/openbuilt/builder/. - */ private function buildDeepLink(string $slug): string { if ($slug === '') { @@ -507,21 +1032,17 @@ private function buildDeepLink(string $slug): string }//end buildDeepLink() /** - * Normalise an OpenRegister object/result entry to a plain PHP array. - * - * @param mixed $item Raw item from ObjectService. - * * @return array */ private function toArray(mixed $item): array { - if (is_array(value: $item) === true) { + if (is_array($item) === true) { return $item; } - if (is_object(value: $item) === true && method_exists($item, 'jsonSerialize') === true) { + if (is_object($item) === true && method_exists($item, 'jsonSerialize') === true) { $serialised = $item->jsonSerialize(); - if (is_array(value: $serialised) === true) { + if (is_array($serialised) === true) { return $serialised; } } @@ -531,13 +1052,7 @@ private function toArray(mixed $item): array }//end toArray() /** - * Extract the UUID from a normalised object array. - * - * Checks multiple common field names to handle different OR object shapes. - * - * @param array $item The normalised object array. - * - * @return string The UUID, or empty string when not found. + * @param array $item */ private function extractUuid(array $item): string { diff --git a/lib/Service/ApplicationCreationService.php b/lib/Service/ApplicationCreationService.php index 67b069f4..2d1f1940 100644 --- a/lib/Service/ApplicationCreationService.php +++ b/lib/Service/ApplicationCreationService.php @@ -136,10 +136,10 @@ public function createApplication(array $payload): string // ---- State tracker for rollback ------------------------------------- // Indexed by version slug. $state = [ - 'applicationUuid' => null, - 'versionUuids' => [], - 'registerSlugs' => [], - 'versionPayloads' => [], + 'applicationUuid' => null, + 'versionUuids' => [], + 'registerSlugs' => [], + 'versionPayloads' => [], ]; // ---- Step 2: Create Application ------------------------------------- @@ -621,6 +621,7 @@ private function provisionRegister( if ($originalSlug !== '') { $blob['slug'] = $slugPrefix.$originalSlug; } + $schema = $this->schemaMapper->createFromArray(object: $blob); $createdIds[] = $schema->getId(); } @@ -870,7 +871,7 @@ public function substituteVersionContext( ) { $page['config']['schema'] = $schemaSlugPrefix.$page['config']['schema']; } - } + }//end foreach unset($page); return $manifest; diff --git a/openspec/changes/openbuilt-nextcloud-nav/.openspec.yaml b/openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/.openspec.yaml similarity index 100% rename from openspec/changes/openbuilt-nextcloud-nav/.openspec.yaml rename to openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/.openspec.yaml diff --git a/openspec/changes/openbuilt-nextcloud-nav/apply-notes.md b/openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/apply-notes.md similarity index 100% rename from openspec/changes/openbuilt-nextcloud-nav/apply-notes.md rename to openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/apply-notes.md diff --git a/openspec/changes/openbuilt-nextcloud-nav/design.md b/openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/design.md similarity index 100% rename from openspec/changes/openbuilt-nextcloud-nav/design.md rename to openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/design.md diff --git a/openspec/changes/openbuilt-nextcloud-nav/proposal.md b/openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/proposal.md similarity index 100% rename from openspec/changes/openbuilt-nextcloud-nav/proposal.md rename to openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/proposal.md diff --git a/openspec/changes/openbuilt-nextcloud-nav/specs/app-icon-management/spec.md b/openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/specs/app-icon-management/spec.md similarity index 100% rename from openspec/changes/openbuilt-nextcloud-nav/specs/app-icon-management/spec.md rename to openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/specs/app-icon-management/spec.md diff --git a/openspec/changes/openbuilt-nextcloud-nav/specs/app-nav-entries/spec.md b/openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/specs/app-nav-entries/spec.md similarity index 92% rename from openspec/changes/openbuilt-nextcloud-nav/specs/app-nav-entries/spec.md rename to openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/specs/app-nav-entries/spec.md index 61c29336..b2c7ef0b 100644 --- a/openspec/changes/openbuilt-nextcloud-nav/specs/app-nav-entries/spec.md +++ b/openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/specs/app-nav-entries/spec.md @@ -74,12 +74,12 @@ are all empty (or absent) SHALL NOT be visible to non-admin users, regardless of - **AND** the Application is published with empty permissions - **THEN** the admin's request cycle includes the nav entry -### Requirement: REQ-OBNAV-003 `group:*` wildcard makes entry visible to all signed-in users +### Requirement: REQ-OBNAV-003 group-wildcard nav-entry visibility SHALL apply to all signed-in users -If the literal string `group:*` appears in any of `permissions.owners`, -`permissions.editors`, or `permissions.viewers` on a published Application, the system SHALL -make the nav entry visible to every signed-in Nextcloud user regardless of their group -memberships. The wildcard SHALL be detected before the group-intersection check runs. +The system SHALL make the nav entry visible to every signed-in Nextcloud user, regardless of +their group memberships, when the literal string `group:*` appears in any of +`permissions.owners`, `permissions.editors`, or `permissions.viewers` on a published +Application. The wildcard SHALL be detected before the group-intersection check runs. #### Scenario: `group:*` in owners makes entry universally visible diff --git a/openspec/changes/openbuilt-nextcloud-nav/specs/openbuilt-application-register/spec.md b/openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/specs/openbuilt-application-register/spec.md similarity index 100% rename from openspec/changes/openbuilt-nextcloud-nav/specs/openbuilt-application-register/spec.md rename to openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/specs/openbuilt-application-register/spec.md diff --git a/openspec/changes/openbuilt-nextcloud-nav/specs/openbuilt-runtime/spec.md b/openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/specs/openbuilt-runtime/spec.md similarity index 95% rename from openspec/changes/openbuilt-nextcloud-nav/specs/openbuilt-runtime/spec.md rename to openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/specs/openbuilt-runtime/spec.md index f5aa45b9..28fe3e98 100644 --- a/openspec/changes/openbuilt-nextcloud-nav/specs/openbuilt-runtime/spec.md +++ b/openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/specs/openbuilt-runtime/spec.md @@ -1,6 +1,6 @@ -## MODIFIED Requirements +## ADDED Requirements -### Requirement: REQ-OBR-007 ApplicationCard renders icon and omits redundant Live chip +### Requirement: REQ-OBR-013 ApplicationCard renders icon and omits redundant Live chip `ApplicationCard.vue` SHALL render the Application's icon in front of the app title using an `` element whose `src` is the URL of the icon-serving light endpoint diff --git a/openspec/changes/openbuilt-nextcloud-nav/tasks.md b/openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/tasks.md similarity index 87% rename from openspec/changes/openbuilt-nextcloud-nav/tasks.md rename to openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/tasks.md index 65961c58..f6c1aa6f 100644 --- a/openspec/changes/openbuilt-nextcloud-nav/tasks.md +++ b/openspec/changes/archive/2026-05-17-openbuilt-nextcloud-nav/tasks.md @@ -1,6 +1,6 @@ ## 1. Schema register patch — top-level icon fields on Application -- [ ] 1.1 **Patch `application` schema in `lib/Settings/openbuilt_register.json`** +- [x] 1.1 **Patch `application` schema in `lib/Settings/openbuilt_register.json`** - spec_ref: REQ-OBICON-001, REQ-OBA-002 (modified) - files: `lib/Settings/openbuilt_register.json` - Add two optional **top-level** properties under @@ -16,7 +16,7 @@ top-level `"icon": { "ref": "app-icon.svg" }` validates against the patched schema; a payload containing top-level `"icon": "string"` fails validation. -- [ ] 1.2 **Re-import schema on upgrade via existing `InitializeSettings` repair step** +- [x] 1.2 **Re-import schema on upgrade via existing `InitializeSettings` repair step** - spec_ref: REQ-OBICON-001 - files: `lib/Repair/InitializeSettings.php` (verify it calls `ConfigurationService::importFromApp('openbuilt')` — no change needed if already correct) @@ -25,7 +25,7 @@ ## 2. Icon-serving PHP layer -- [ ] 2.1 **Create `lib/Service/IconService.php`** +- [x] 2.1 **Create `lib/Service/IconService.php`** - spec_ref: REQ-OBICON-002, REQ-OBICON-003 - files: `lib/Service/IconService.php` (NEW) - Methods: @@ -34,7 +34,7 @@ - Must carry SPDX + EUPL-1.2 docblock per project standards. - acceptance_criteria: PHPUnit: mock ObjectService to return a stream; assert correct bytes returned. Mock ObjectService to throw; assert fallback stream returned. `composer check:strict` passes. -- [ ] 2.2 **Create `lib/Controller/IconController.php`** +- [x] 2.2 **Create `lib/Controller/IconController.php`** - spec_ref: REQ-OBICON-002, REQ-OBICON-003 - files: `lib/Controller/IconController.php` (NEW) - Methods: @@ -43,7 +43,7 @@ - Both methods carry `#[NoAdminRequired]`. - acceptance_criteria: PHPUnit: mock IconService; assert 200 + correct headers. `composer check:strict` passes. -- [ ] 2.3 **Register icon routes in `appinfo/routes.php`** +- [x] 2.3 **Register icon routes in `appinfo/routes.php`** - spec_ref: REQ-OBICON-002, REQ-OBICON-003 - files: `appinfo/routes.php` - Add before the SPA catch-all, after the exports routes: @@ -55,7 +55,7 @@ ## 3. Navigation wiring -- [ ] 3.1 **Create `lib/Service/AppNavigationService.php`** +- [x] 3.1 **Create `lib/Service/AppNavigationService.php`** - spec_ref: REQ-OBNAV-001, REQ-OBNAV-002, REQ-OBNAV-003, REQ-OBNAV-004 - files: `lib/Service/AppNavigationService.php` (NEW) - Responsibilities: @@ -64,7 +64,7 @@ - Order: `1000 + (abs(crc32($slug)) % 1000)` — deterministic, alpha-spread, after openbuilt's own static entry. - acceptance_criteria: PHPUnit: mock ObjectService to return one published + one draft Application; assert only the published entry is registered; assert gating closure returns `true` for matching user and `false` for non-matching user; `composer check:strict` passes. -- [ ] 3.2 **Wire `AppNavigationService` inside `Application::boot()`** +- [x] 3.2 **Wire `AppNavigationService` inside `Application::boot()`** - spec_ref: REQ-OBNAV-001 - files: `lib/AppInfo/Application.php` - Inside the currently-empty `boot()` method, resolve `AppNavigationService` lazily and call `registerNavEntries()`: @@ -80,7 +80,7 @@ ## 4. Frontend — ApplicationCard update -- [ ] 4.1 **Add icon `` before the title in `ApplicationCard.vue`** +- [x] 4.1 **Add icon `` before the title in `ApplicationCard.vue`** - spec_ref: REQ-OBR-007 - files: `src/components/ApplicationCard.vue` - In the `ob-app-card__head` div, add before `

`: @@ -98,7 +98,7 @@ - Add `.ob-app-card__icon { width: 20px; height: 20px; object-fit: contain; flex-shrink: 0; }` to `