feat(framework+cms): generic app-lane module fixtures + CMS content model
Framework: a generic, DRY module-install path so an app lane (CMS/ERP/Helpdesk) declares its DocTypes as fixtures (framework.RegisterModule, sibling to RegisterHook) and installs them per-org via the engine's own gate: GET /v1/framework/modules list registered lanes GET /v1/framework/modules/:module lane fixtures + which are installed in-org POST /v1/framework/modules/:module/install ensure fixtures exist (managerOnly, idempotent) 'modules' reserved so the static routes are never shadowed by a document route. CMS (clients/cms): the first lane — the content model as fixtures only, NO HTTP surface of its own. Page/Post/Article (slug-named, status Draft/Published, author Link), Media (Attach-backed DAM), Navigation (JSON menu), Author. A CMS collection IS a framework DocType (module 'cms'); content IS documents; publishing IS a status field. Registered at init; installed per-org. Secure by default: install is managerOnly (owner seeded trust-on-first-use), create-if-absent (never clobbers a customised DocType), stamps the module tag, and every op stays per-org via principal.Tenant. Tests: install/idempotency/unknown-404/tenant-isolation/forged-principal-403/ non-owner-403/module-tag; CMS fixture validity + content-model spec + a full HTTP install->create Author->create Page(link)->publish->filter round-trip.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
// Package cms declares the Hanzo CMS content model as DocType fixtures on the
|
||||
// framework engine (clients/framework). CMS is NOT a bespoke subsystem and mounts
|
||||
// NO HTTP surface of its own: a "collection" IS a framework DocType in module
|
||||
// "cms", a content entry IS a framework document, media IS an Attach-backed
|
||||
// document, and publishing IS a status field. All CRUD, permissions, tenant
|
||||
// isolation, and install are the framework's generic, already-live surface
|
||||
// (/v1/framework/*). This package only DECLARES the fixtures and registers them
|
||||
// with the engine at init — the ONE source of truth for the CMS content model,
|
||||
// per-org on Base/SQLite.
|
||||
//
|
||||
// This is the first app lane on the framework and the template for the rest:
|
||||
// ERPNext DocTypes and Helpdesk register their fixtures the same way
|
||||
// (framework.RegisterModule) and are installed + rendered by the SAME generic
|
||||
// install path and the SAME generic @hanzo/ui DocType renderer. One engine, one
|
||||
// renderer, every business app.
|
||||
package cms
|
||||
|
||||
import "github.com/hanzoai/cloud/clients/framework"
|
||||
|
||||
// Module is the framework module tag every CMS DocType carries. The console's CMS
|
||||
// surface is the generic DocType renderer scoped to this module.
|
||||
const Module = "cms"
|
||||
|
||||
// RoleContentEditor is the editorial role the CMS content DocTypes grant read/
|
||||
// write/create/delete. The org owner (System Manager, seeded trust-on-first-use)
|
||||
// assigns it to teammates via /v1/framework/roles; a role-less member stays
|
||||
// denied (secure by default). Publishing is a status write, so it needs no extra
|
||||
// right.
|
||||
const RoleContentEditor = "Content Editor"
|
||||
|
||||
// init registers the CMS content model with the framework. Installing the "cms"
|
||||
// module (POST /v1/framework/modules/cms/install) ensures these DocTypes exist in
|
||||
// the caller's org.
|
||||
func init() { framework.RegisterModule(Module, DocTypes()) }
|
||||
|
||||
// DocTypes returns the canonical CMS content model — the default collections an
|
||||
// org gets when it installs the CMS lane. Order matters only for readability;
|
||||
// Link targets are resolved at document write, not at define, so a DocType may
|
||||
// reference another that is defined later in the set.
|
||||
func DocTypes() []framework.DocType {
|
||||
return []framework.DocType{author(), media(), page(), post(), article(), navigation()}
|
||||
}
|
||||
|
||||
// ---- content DocTypes ----
|
||||
|
||||
// author is a content author profile — the Link target for the author field on
|
||||
// pages/posts/articles. Hash-named (an author needs no URL slug); the display
|
||||
// name is the title.
|
||||
func author() framework.DocType {
|
||||
return framework.DocType{
|
||||
Name: "Author", Module: Module, TitleField: "name",
|
||||
Fields: []framework.DocField{
|
||||
{Fieldname: "name", Fieldtype: framework.FieldData, Label: "Name", Reqd: true, InListView: true},
|
||||
{Fieldname: "email", Fieldtype: framework.FieldData, Label: "Email", InListView: true},
|
||||
{Fieldname: "bio", Fieldtype: framework.FieldText, Label: "Bio"},
|
||||
{Fieldname: "avatar", Fieldtype: framework.FieldAttach, Label: "Avatar"},
|
||||
},
|
||||
Perms: contentPerms(),
|
||||
}
|
||||
}
|
||||
|
||||
// media is the DAM: an Attach-backed catalogue of uploaded assets (the Attach
|
||||
// holds the object URL under the org's S3/SeaweedFS prefix). Hash-named.
|
||||
func media() framework.DocType {
|
||||
return framework.DocType{
|
||||
Name: "Media", Module: Module, TitleField: "title",
|
||||
Fields: []framework.DocField{
|
||||
{Fieldname: "title", Fieldtype: framework.FieldData, Label: "Title", Reqd: true, InListView: true},
|
||||
{Fieldname: "file", Fieldtype: framework.FieldAttach, Label: "File", Reqd: true, InListView: true},
|
||||
{Fieldname: "alt", Fieldtype: framework.FieldData, Label: "Alt Text"},
|
||||
{Fieldname: "mime", Fieldtype: framework.FieldData, Label: "Type", InListView: true},
|
||||
{Fieldname: "size", Fieldtype: framework.FieldInt, Label: "Size"},
|
||||
{Fieldname: "width", Fieldtype: framework.FieldInt, Label: "Width"},
|
||||
{Fieldname: "height", Fieldtype: framework.FieldInt, Label: "Height"},
|
||||
{Fieldname: "folder", Fieldtype: framework.FieldData, Label: "Folder", InListView: true},
|
||||
},
|
||||
Perms: contentPerms(),
|
||||
}
|
||||
}
|
||||
|
||||
// page is site structure (marketing/product pages). Slug-named: the document name
|
||||
// IS the slug, so a page's stable URL key is unique per org.
|
||||
func page() framework.DocType {
|
||||
return framework.DocType{
|
||||
Name: "Page", Module: Module, Autoname: "field:slug", TitleField: "title",
|
||||
Fields: append(baseContentFields(),
|
||||
seoTitle(), seoDescription()),
|
||||
Perms: contentPerms(),
|
||||
}
|
||||
}
|
||||
|
||||
// post is a blog/changelog entry: the base content plus a category and an explicit
|
||||
// publish timestamp.
|
||||
func post() framework.DocType {
|
||||
return framework.DocType{
|
||||
Name: "Post", Module: Module, Autoname: "field:slug", TitleField: "title",
|
||||
Fields: append(baseContentFields(),
|
||||
category(),
|
||||
framework.DocField{Fieldname: "published_at", Fieldtype: framework.FieldDatetime, Label: "Published At"}),
|
||||
Perms: contentPerms(),
|
||||
}
|
||||
}
|
||||
|
||||
// article is a knowledge-base / docs article: the base content plus a category.
|
||||
func article() framework.DocType {
|
||||
return framework.DocType{
|
||||
Name: "Article", Module: Module, Autoname: "field:slug", TitleField: "title",
|
||||
Fields: append(baseContentFields(), category()),
|
||||
Perms: contentPerms(),
|
||||
}
|
||||
}
|
||||
|
||||
// navigation is a site menu (header/footer/sidebar): an ordered, optionally nested
|
||||
// list of links held as JSON ([{label,url,children}]).
|
||||
func navigation() framework.DocType {
|
||||
return framework.DocType{
|
||||
Name: "Navigation", Module: Module, Autoname: "field:slug", TitleField: "title",
|
||||
Fields: []framework.DocField{
|
||||
{Fieldname: "title", Fieldtype: framework.FieldData, Label: "Title", Reqd: true, InListView: true},
|
||||
{Fieldname: "slug", Fieldtype: framework.FieldData, Label: "Slug", Reqd: true, InListView: true},
|
||||
{Fieldname: "location", Fieldtype: framework.FieldSelect, Label: "Location", Options: "header\nfooter\nsidebar", Default: "header", InListView: true},
|
||||
{Fieldname: "items", Fieldtype: framework.FieldJSON, Label: "Items"},
|
||||
},
|
||||
Perms: contentPerms(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shared field builders (DRY: page/post/article share these) ----
|
||||
|
||||
// baseContentFields is the field set every publishable content DocType shares:
|
||||
// title, URL slug, rich body, excerpt, the publish status, a linked author, a
|
||||
// featured image, and tags. Returned fresh each call (append-safe).
|
||||
func baseContentFields() []framework.DocField {
|
||||
return []framework.DocField{
|
||||
{Fieldname: "title", Fieldtype: framework.FieldData, Label: "Title", Reqd: true, InListView: true},
|
||||
{Fieldname: "slug", Fieldtype: framework.FieldData, Label: "Slug", Reqd: true, InListView: true},
|
||||
{Fieldname: "body", Fieldtype: framework.FieldText, Label: "Body"},
|
||||
{Fieldname: "excerpt", Fieldtype: framework.FieldSmall, Label: "Excerpt"},
|
||||
{Fieldname: "status", Fieldtype: framework.FieldSelect, Label: "Status", Options: "Draft\nPublished", Default: "Draft", InListView: true},
|
||||
{Fieldname: "author", Fieldtype: framework.FieldLink, Label: "Author", Options: "Author"},
|
||||
{Fieldname: "featured_image", Fieldtype: framework.FieldAttach, Label: "Featured Image"},
|
||||
{Fieldname: "tags", Fieldtype: framework.FieldData, Label: "Tags"},
|
||||
}
|
||||
}
|
||||
|
||||
func category() framework.DocField {
|
||||
return framework.DocField{Fieldname: "category", Fieldtype: framework.FieldData, Label: "Category", InListView: true}
|
||||
}
|
||||
func seoTitle() framework.DocField {
|
||||
return framework.DocField{Fieldname: "seo_title", Fieldtype: framework.FieldData, Label: "SEO Title"}
|
||||
}
|
||||
func seoDescription() framework.DocField {
|
||||
return framework.DocField{Fieldname: "seo_description", Fieldtype: framework.FieldSmall, Label: "SEO Description"}
|
||||
}
|
||||
|
||||
// contentPerms is the shared permission set: the org owner (System Manager) has
|
||||
// full rights; a granted Content Editor may read/write/create/delete. A role-less
|
||||
// member is denied — secure by default (the engine seeds a System-Manager-only
|
||||
// grant when perms are empty, so this is an explicit widening, never a loosening).
|
||||
func contentPerms() []framework.DocPerm {
|
||||
return []framework.DocPerm{
|
||||
{Role: framework.RoleSystemManager, Read: true, Write: true, Create: true, Delete: true, Submit: true, Cancel: true},
|
||||
{Role: RoleContentEditor, Read: true, Write: true, Create: true, Delete: true},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package cms
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/framework"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// TestFixturesValid proves every CMS DocType is a well-formed schema and that its
|
||||
// Link targets resolve WITHIN the lane — so installing the module yields a
|
||||
// self-consistent content model (no dangling Link target that would 422 at write).
|
||||
func TestFixturesValid(t *testing.T) {
|
||||
dts := DocTypes()
|
||||
names := map[string]framework.DocType{}
|
||||
for _, dt := range dts {
|
||||
if err := dt.Validate(); err != nil {
|
||||
t.Fatalf("fixture %q invalid: %v", dt.Name, err)
|
||||
}
|
||||
names[dt.Name] = dt
|
||||
}
|
||||
for _, dt := range dts {
|
||||
for _, f := range dt.Fields {
|
||||
if f.Fieldtype == framework.FieldLink {
|
||||
if _, ok := names[f.Options]; !ok {
|
||||
t.Fatalf("%s.%s links to %q which is not a CMS DocType", dt.Name, f.Fieldname, f.Options)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestContentModelSpec locks the content-model contract the console's generic
|
||||
// DocType renderer relies on: the publishable content types, their publish status
|
||||
// field (Draft/Published), the slug autoname, and the author Link.
|
||||
func TestContentModelSpec(t *testing.T) {
|
||||
byName := map[string]framework.DocType{}
|
||||
for _, dt := range DocTypes() {
|
||||
if dt.Module != Module {
|
||||
t.Fatalf("%s: module want %q, got %q", dt.Name, Module, dt.Module)
|
||||
}
|
||||
byName[dt.Name] = dt
|
||||
}
|
||||
for _, want := range []string{"Author", "Media", "Page", "Post", "Article", "Navigation"} {
|
||||
if _, ok := byName[want]; !ok {
|
||||
t.Fatalf("missing CMS DocType %q", want)
|
||||
}
|
||||
}
|
||||
// The three publishable content types share the publish contract.
|
||||
for _, name := range []string{"Page", "Post", "Article"} {
|
||||
dt := byName[name]
|
||||
if dt.Autoname != "field:slug" {
|
||||
t.Fatalf("%s: autoname want field:slug, got %q", name, dt.Autoname)
|
||||
}
|
||||
status, ok := fieldOf(dt, "status")
|
||||
if !ok || status.Fieldtype != framework.FieldSelect || status.Default != "Draft" {
|
||||
t.Fatalf("%s: expected a Select status field defaulting to Draft, got %+v", name, status)
|
||||
}
|
||||
if status.Options != "Draft\nPublished" {
|
||||
t.Fatalf("%s: status options want Draft/Published, got %q", name, status.Options)
|
||||
}
|
||||
author, ok := fieldOf(dt, "author")
|
||||
if !ok || author.Fieldtype != framework.FieldLink || author.Options != "Author" {
|
||||
t.Fatalf("%s: expected an author Link to Author, got %+v", name, author)
|
||||
}
|
||||
}
|
||||
// Media is Attach-backed (the DAM).
|
||||
if file, ok := fieldOf(byName["Media"], "file"); !ok || file.Fieldtype != framework.FieldAttach || !file.Reqd {
|
||||
t.Fatalf("Media.file must be a required Attach, got %+v", file)
|
||||
}
|
||||
}
|
||||
|
||||
func fieldOf(dt framework.DocType, name string) (framework.DocField, bool) {
|
||||
for _, f := range dt.Fields {
|
||||
if f.Fieldname == name {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
return framework.DocField{}, false
|
||||
}
|
||||
|
||||
// TestInstallAndPublishRoundTrip drives the WHOLE content model through the real
|
||||
// engine over HTTP: install the lane, create an author, create a page that links
|
||||
// it (status Draft), then publish it and read it back by slug — proving publish =
|
||||
// a status field and the Link integrity holds.
|
||||
func TestInstallAndPublishRoundTrip(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := framework.Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir()}); err != nil {
|
||||
t.Fatalf("mount framework: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = framework.Shutdown() })
|
||||
const org = "acme"
|
||||
|
||||
// Install the CMS lane (the caller becomes System Manager, trust-on-first-use).
|
||||
if code, body := call(t, app, http.MethodPost, "/v1/framework/modules/cms/install", org, nil); code != http.StatusOK {
|
||||
t.Fatalf("install cms want 200, got %d (%s)", code, body)
|
||||
}
|
||||
|
||||
// Create an Author (hash-named) to link.
|
||||
code, body := call(t, app, http.MethodPost, "/v1/framework/Author", org, map[string]any{"name": "Ada Lovelace", "email": "ada@example.com"})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create author want 201, got %d (%s)", code, body)
|
||||
}
|
||||
var author map[string]any
|
||||
_ = json.Unmarshal(body, &author)
|
||||
authorName, _ := author["name"].(string) // hash id
|
||||
if authorName == "" {
|
||||
t.Fatalf("author has no name: %s", body)
|
||||
}
|
||||
|
||||
// Create a Page linking the author; slug becomes the document name; Draft.
|
||||
code, body = call(t, app, http.MethodPost, "/v1/framework/Page", org, map[string]any{
|
||||
"title": "About Us", "slug": "about-us", "body": "<h1>About</h1>", "author": authorName,
|
||||
})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create page want 201, got %d (%s)", code, body)
|
||||
}
|
||||
var page map[string]any
|
||||
_ = json.Unmarshal(body, &page)
|
||||
if page["name"] != "about-us" {
|
||||
t.Fatalf("page name (autoname field:slug) want about-us, got %v", page["name"])
|
||||
}
|
||||
if page["status"] != "Draft" {
|
||||
t.Fatalf("new page status want Draft (default), got %v", page["status"])
|
||||
}
|
||||
|
||||
// No published pages yet.
|
||||
if n := listCount(t, app, org, `/v1/framework/Page?filters={"status":"Published"}`); n != 0 {
|
||||
t.Fatalf("published pages want 0, got %d", n)
|
||||
}
|
||||
|
||||
// Publish = set the status field.
|
||||
if code, body := call(t, app, http.MethodPut, "/v1/framework/Page/about-us", org, map[string]any{
|
||||
"title": "About Us", "slug": "about-us", "status": "Published", "author": authorName,
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("publish page want 200, got %d (%s)", code, body)
|
||||
}
|
||||
if n := listCount(t, app, org, `/v1/framework/Page?filters={"status":"Published"}`); n != 1 {
|
||||
t.Fatalf("published pages want 1, got %d", n)
|
||||
}
|
||||
|
||||
// A dangling author Link is refused (422) — Link integrity within the org.
|
||||
if code, _ := call(t, app, http.MethodPost, "/v1/framework/Page", org, map[string]any{
|
||||
"title": "Ghost", "slug": "ghost", "author": "nonexistent-author",
|
||||
}); code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("dangling author link want 422, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- test HTTP harness (a validated principal u_<org>) ----
|
||||
|
||||
func call(t *testing.T, app *zip.App, method, path, org string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
req.Header.Set("X-User-Id", "u_"+org) // the validated-principal signal
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, b
|
||||
}
|
||||
|
||||
func listCount(t *testing.T, app *zip.App, org, path string) int {
|
||||
t.Helper()
|
||||
code, body := call(t, app, http.MethodGet, path, org, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("list %s want 200, got %d (%s)", path, code, body)
|
||||
}
|
||||
var res struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &res)
|
||||
return len(res.Data)
|
||||
}
|
||||
@@ -99,7 +99,7 @@ var needsOptions = map[string]bool{FieldSelect: true, FieldLink: true, FieldTabl
|
||||
// why the static routes can be registered before the generic /:doctype routes
|
||||
// without ambiguity.
|
||||
var reservedDocTypeNames = map[string]bool{
|
||||
"doctypes": true, "roles": true, "health": true, "summary": true,
|
||||
"doctypes": true, "roles": true, "health": true, "summary": true, "modules": true,
|
||||
}
|
||||
|
||||
// Limits. maxField bounds a single scalar text value; maxDocBytes bounds a whole
|
||||
|
||||
@@ -64,6 +64,14 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
app.Post("/v1/framework/roles", s.assignRole)
|
||||
app.Delete("/v1/framework/roles/:user/:role", s.revokeRole)
|
||||
|
||||
// App-lane module fixtures: list the registered lanes, inspect one, and
|
||||
// install its DocType fixtures into the caller's org. Registered BEFORE the
|
||||
// generic /:doctype routes so "modules" resolves to these static handlers, and
|
||||
// "modules" is a reserved DocType name so no document route can shadow them.
|
||||
app.Get("/v1/framework/modules", s.listModules)
|
||||
app.Get("/v1/framework/modules/:module", s.getModule)
|
||||
app.Post("/v1/framework/modules/:module/install", s.installModule)
|
||||
|
||||
// GENERIC metadata-driven document surface.
|
||||
app.Get("/v1/framework/:doctype", s.listDocuments)
|
||||
app.Post("/v1/framework/:doctype", s.createDocument)
|
||||
@@ -232,6 +240,96 @@ func (s *svc) revokeRole(c *zip.Ctx) error {
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- Module (app-lane fixture) handlers ----
|
||||
|
||||
// listModules reports the app lanes registered in this binary and the DocType
|
||||
// names each installs. Any validated member may read the catalog (it is
|
||||
// compile-time first-party data, not tenant data); installing is gated separately.
|
||||
func (s *svc) listModules(c *zip.Ctx) error {
|
||||
if _, err := s.resolveAccess(c); err != nil {
|
||||
return err
|
||||
}
|
||||
mods := registeredModules()
|
||||
out := make([]map[string]any, 0, len(mods))
|
||||
for _, m := range mods {
|
||||
out = append(out, map[string]any{"module": m, "doctypes": fixtureNames(moduleFixtures(m))})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"data": out})
|
||||
}
|
||||
|
||||
// getModule reports one lane's fixtures and which of them are already installed in
|
||||
// the caller's org — the honest install-state the UI shows ("set up" vs "installed").
|
||||
func (s *svc) getModule(c *zip.Ctx) error {
|
||||
acc, err := s.resolveAccess(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
module := strings.TrimSpace(c.Param("module"))
|
||||
fx := moduleFixtures(module)
|
||||
if len(fx) == 0 {
|
||||
return zip.ErrNotFound("unknown module: " + module)
|
||||
}
|
||||
installed := make([]string, 0, len(fx))
|
||||
for _, dt := range fx {
|
||||
if _, err := s.store.GetDocType(c.Context(), acc.org, dt.Name); err == nil {
|
||||
installed = append(installed, dt.Name)
|
||||
} else if !errors.Is(err, errNotFound) {
|
||||
return mapErr(err, "")
|
||||
}
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"module": module, "doctypes": fixtureNames(fx), "installed": installed})
|
||||
}
|
||||
|
||||
// installModule ensures every registered DocType of a lane exists in the caller's
|
||||
// org. It is idempotent (create-if-absent — an org's customised DocType is NEVER
|
||||
// clobbered) and gated by managerOnly, so the org owner (System Manager, seeded
|
||||
// trust-on-first-use) installs a lane into their OWN tenant and no one else's. The
|
||||
// module name is stamped onto each fixture so the lane's DocTypes are discoverable
|
||||
// by `module`.
|
||||
func (s *svc) installModule(c *zip.Ctx) error {
|
||||
acc, err := s.managerOnly(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
module := strings.TrimSpace(c.Param("module"))
|
||||
fx := moduleFixtures(module)
|
||||
if len(fx) == 0 {
|
||||
return zip.ErrNotFound("unknown module: " + module)
|
||||
}
|
||||
created := make([]string, 0, len(fx))
|
||||
existing := make([]string, 0, len(fx))
|
||||
for _, dt := range fx {
|
||||
if _, err := s.store.GetDocType(c.Context(), acc.org, dt.Name); err == nil {
|
||||
existing = append(existing, dt.Name)
|
||||
continue
|
||||
} else if !errors.Is(err, errNotFound) {
|
||||
return mapErr(err, "")
|
||||
}
|
||||
dt.Module = module // the lane owns the module tag
|
||||
if err := dt.Validate(); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "fixture %q invalid: %v", dt.Name, err)
|
||||
}
|
||||
if _, err := s.store.CreateDocType(c.Context(), acc.org, dt); err != nil {
|
||||
if errors.Is(err, errConflict) { // lost a race with a concurrent install
|
||||
existing = append(existing, dt.Name)
|
||||
continue
|
||||
}
|
||||
return mapErr(err, "")
|
||||
}
|
||||
created = append(created, dt.Name)
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"module": module, "created": created, "existing": existing})
|
||||
}
|
||||
|
||||
// fixtureNames returns the ordered DocType names of a fixture set.
|
||||
func fixtureNames(fx []DocType) []string {
|
||||
names := make([]string, len(fx))
|
||||
for i, dt := range fx {
|
||||
names[i] = dt.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ---- Document handlers ----
|
||||
|
||||
func (s *svc) createDocument(c *zip.Ctx) error {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package framework
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Modules — the app-lane fixture registry.
|
||||
//
|
||||
// A sibling app lane (cms, erp, helpdesk, …) DECLARES its content model as a set
|
||||
// of DocType fixtures and registers them here from a package init(), exactly as
|
||||
// hooks are registered (hook.go). Installing a module into an org ensures those
|
||||
// DocTypes exist in that org's per-org store — the Frappe "install app / load
|
||||
// fixtures" step, decomplected to pure data. This is the ONE way an app lane
|
||||
// seeds its content model: it never forks the engine and never hand-rolls a
|
||||
// second install path. CMS is the first lane; ERP/CRM/Helpdesk register the same
|
||||
// way and reuse the SAME generic install below.
|
||||
//
|
||||
// The registry is process-global (fixtures are compile-time first-party data,
|
||||
// like hooks), read under a mutex so a late init() registration is still safe.
|
||||
// Installation itself is per-org and gated (managerOnly) at the HTTP layer, so a
|
||||
// module's fixtures only ever land in an org an authorized owner installs them
|
||||
// into — the registry holds NO tenant state.
|
||||
var (
|
||||
moduleMu sync.RWMutex
|
||||
moduleRegistry = map[string][]DocType{}
|
||||
)
|
||||
|
||||
// RegisterModule declares the DocType fixtures a module installs. Call from a
|
||||
// package init() so the content model is declared once at build time. The module
|
||||
// name is stamped onto every fixture at install so a lane's DocTypes are always
|
||||
// discoverable by `module`. Fixtures are cloned on registration so a caller's
|
||||
// slice can never mutate the registry.
|
||||
func RegisterModule(module string, fixtures []DocType) {
|
||||
if module == "" || len(fixtures) == 0 {
|
||||
return
|
||||
}
|
||||
cp := make([]DocType, len(fixtures))
|
||||
copy(cp, fixtures)
|
||||
moduleMu.Lock()
|
||||
defer moduleMu.Unlock()
|
||||
moduleRegistry[module] = append(moduleRegistry[module], cp...)
|
||||
}
|
||||
|
||||
// moduleFixtures returns the registered fixtures for a module (nil if none).
|
||||
func moduleFixtures(module string) []DocType {
|
||||
moduleMu.RLock()
|
||||
defer moduleMu.RUnlock()
|
||||
return moduleRegistry[module]
|
||||
}
|
||||
|
||||
// registeredModules returns the sorted set of registered module names.
|
||||
func registeredModules() []string {
|
||||
moduleMu.RLock()
|
||||
defer moduleMu.RUnlock()
|
||||
out := make([]string, 0, len(moduleRegistry))
|
||||
for m := range moduleRegistry {
|
||||
out = append(out, m)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// resetModules clears the registry. TEST-ONLY, mirroring resetHooks — keeps
|
||||
// install tests independent of process-global registrations.
|
||||
func resetModules() {
|
||||
moduleMu.Lock()
|
||||
defer moduleMu.Unlock()
|
||||
moduleRegistry = map[string][]DocType{}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package framework
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testFixtures is a tiny two-DocType lane used to exercise the generic install
|
||||
// path without depending on any real app lane (clients/cms imports framework, so
|
||||
// framework's own tests can't import it back).
|
||||
func testFixtures() []DocType {
|
||||
return []DocType{
|
||||
{Name: "Widget", Fields: []DocField{{Fieldname: "code", Fieldtype: FieldData, Reqd: true}}},
|
||||
{Name: "Gadget", Module: "wrong", Fields: []DocField{{Fieldname: "label", Fieldtype: FieldData}}},
|
||||
}
|
||||
}
|
||||
|
||||
// withTestModule registers a lane for the duration of a test and resets the
|
||||
// process-global registry afterward (mirrors resetHooks in the hook tests).
|
||||
func withTestModule(t *testing.T, module string, fx []DocType) {
|
||||
t.Helper()
|
||||
resetModules()
|
||||
RegisterModule(module, fx)
|
||||
t.Cleanup(resetModules)
|
||||
}
|
||||
|
||||
func TestInstallModule_CreatesFixturesStampedWithModule(t *testing.T) {
|
||||
withTestModule(t, "shop", testFixtures())
|
||||
app := mountApp(t)
|
||||
|
||||
code, body := do(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("install want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var res struct {
|
||||
Module string `json:"module"`
|
||||
Created []string `json:"created"`
|
||||
Existing []string `json:"existing"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &res)
|
||||
if res.Module != "shop" || len(res.Created) != 2 || len(res.Existing) != 0 {
|
||||
t.Fatalf("install result mismatch: %+v", res)
|
||||
}
|
||||
|
||||
// The fixtures now exist in the org AND carry the module tag (Gadget's stray
|
||||
// "wrong" module is overwritten with the lane's own name).
|
||||
code, body = do(t, app, http.MethodGet, "/v1/framework/doctypes/Gadget", "acme", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("get installed doctype want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var dt DocType
|
||||
_ = json.Unmarshal(body, &dt)
|
||||
if dt.Module != "shop" {
|
||||
t.Fatalf("installed doctype module want %q, got %q", "shop", dt.Module)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallModule_Idempotent(t *testing.T) {
|
||||
withTestModule(t, "shop", testFixtures())
|
||||
app := mountApp(t)
|
||||
|
||||
if code, body := do(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", nil); code != http.StatusOK {
|
||||
t.Fatalf("first install want 200, got %d (%s)", code, body)
|
||||
}
|
||||
// Second install creates nothing and reports everything as already present.
|
||||
code, body := do(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", nil)
|
||||
var res struct {
|
||||
Created []string `json:"created"`
|
||||
Existing []string `json:"existing"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &res)
|
||||
if code != http.StatusOK || len(res.Created) != 0 || len(res.Existing) != 2 {
|
||||
t.Fatalf("idempotent install want created=0 existing=2, got %d %+v", code, res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallModule_UnknownModule404(t *testing.T) {
|
||||
withTestModule(t, "shop", testFixtures())
|
||||
app := mountApp(t)
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/framework/modules/ghost/install", "acme", nil); code != http.StatusNotFound {
|
||||
t.Fatalf("unknown module install want 404, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallModule_TenantIsolation: installing into one org NEVER creates the
|
||||
// lane's DocTypes in another org.
|
||||
func TestInstallModule_TenantIsolation(t *testing.T) {
|
||||
withTestModule(t, "shop", testFixtures())
|
||||
app := mountApp(t)
|
||||
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", nil); code != http.StatusOK {
|
||||
t.Fatal("install into acme failed")
|
||||
}
|
||||
// A different tenant sees NONE of acme's installed DocTypes.
|
||||
code, body := do(t, app, http.MethodGet, "/v1/framework/doctypes", "victim", nil)
|
||||
var list struct {
|
||||
Data []DocType `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &list)
|
||||
if code != http.StatusOK || len(list.Data) != 0 {
|
||||
t.Fatalf("victim org must have zero doctypes, got %d %+v", code, list.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallModule_ForgedPrincipalRefused: an X-Org-Id with no validated
|
||||
// principal (no X-User-Id) is refused before any store access.
|
||||
func TestInstallModule_ForgedPrincipalRefused(t *testing.T) {
|
||||
withTestModule(t, "shop", testFixtures())
|
||||
app := mountApp(t)
|
||||
// call with empty user = no validated principal.
|
||||
if code, _ := call(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "victim", "", false, nil); code != http.StatusForbidden {
|
||||
t.Fatalf("forged-principal install want 403, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallModule_NonOwnerDenied: after the owner is seeded (trust-on-first-use),
|
||||
// a different member of the same org who is not a System Manager cannot install.
|
||||
func TestInstallModule_NonOwnerDenied(t *testing.T) {
|
||||
withTestModule(t, "shop", testFixtures())
|
||||
app := mountApp(t)
|
||||
|
||||
// u_acme installs first → becomes System Manager (owner seed).
|
||||
if code, _ := call(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", "u_acme", false, nil); code != http.StatusOK {
|
||||
t.Fatal("owner install failed")
|
||||
}
|
||||
// A different, non-admin member of acme is denied (org is now owned).
|
||||
if code, _ := call(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", "u_intruder", false, nil); code != http.StatusForbidden {
|
||||
t.Fatalf("non-owner install want 403, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndGetModule(t *testing.T) {
|
||||
withTestModule(t, "shop", testFixtures())
|
||||
app := mountApp(t)
|
||||
|
||||
// listModules
|
||||
code, body := do(t, app, http.MethodGet, "/v1/framework/modules", "acme", nil)
|
||||
var lst struct {
|
||||
Data []struct {
|
||||
Module string `json:"module"`
|
||||
Doctypes []string `json:"doctypes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &lst)
|
||||
if code != http.StatusOK || len(lst.Data) != 1 || lst.Data[0].Module != "shop" || len(lst.Data[0].Doctypes) != 2 {
|
||||
t.Fatalf("listModules mismatch: %d %+v", code, lst.Data)
|
||||
}
|
||||
|
||||
// getModule before install → installed empty.
|
||||
code, body = do(t, app, http.MethodGet, "/v1/framework/modules/shop", "acme", nil)
|
||||
var g struct {
|
||||
Doctypes []string `json:"doctypes"`
|
||||
Installed []string `json:"installed"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &g)
|
||||
if code != http.StatusOK || len(g.Doctypes) != 2 || len(g.Installed) != 0 {
|
||||
t.Fatalf("getModule (pre-install) mismatch: %d %+v", code, g)
|
||||
}
|
||||
|
||||
// Install, then getModule → installed lists both.
|
||||
do(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", nil)
|
||||
_, body = do(t, app, http.MethodGet, "/v1/framework/modules/shop", "acme", nil)
|
||||
_ = json.Unmarshal(body, &g)
|
||||
if len(g.Installed) != 2 {
|
||||
t.Fatalf("getModule (post-install) installed want 2, got %+v", g.Installed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallModule_ReservedRouteNotShadowed proves "modules" is a reserved
|
||||
// DocType name, so the static module routes can never be shadowed by a document
|
||||
// route (define of a DocType named "modules" is refused).
|
||||
func TestInstallModule_ReservedRouteNotShadowed(t *testing.T) {
|
||||
if err := (&DocType{Name: "modules", Fields: []DocField{{Fieldname: "a", Fieldtype: FieldData}}}).Validate(); err == nil {
|
||||
t.Fatal("DocType named \"modules\" must be reserved (Validate should fail)")
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,13 @@ import (
|
||||
// runtime dep). Order 129 binds /v1/framework/* before the AI /v1/* catch-all.
|
||||
_ "github.com/hanzoai/cloud/clients/framework" // order 129 — /v1/framework/* (DocType engine)
|
||||
|
||||
// The CMS app lane: DocType fixtures (Page/Post/Article/Media/Navigation/
|
||||
// Author, module "cms") registered with the framework at init. It mounts NO
|
||||
// HTTP surface of its own — CMS content IS documents on /v1/framework/*,
|
||||
// installed per-org via /v1/framework/modules/cms/install. First lane on the
|
||||
// engine; ERP/Helpdesk register the same way.
|
||||
_ "github.com/hanzoai/cloud/clients/cms" // (no order) — registers the "cms" framework module
|
||||
|
||||
_ "github.com/hanzoai/cloud/clients/functions" // order 128 — /v1/functions/*
|
||||
_ "github.com/hanzoai/cloud/clients/git" // order 132 — /v1/git/* (S3-backed native Git hosting; smart-HTTP clone/push)
|
||||
_ "github.com/hanzoai/cloud/clients/prompts" // order 126 — /v1/prompts/*
|
||||
|
||||
Reference in New Issue
Block a user