From 8bce07def3d6cbd7746295680e748bd28eb73e0a Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:53:53 +0200 Subject: [PATCH 01/43] feat(frames): add the Frame template model and requirement check --- backend/internal/frames/templates.go | 258 ++++++++++++++++++ backend/internal/frames/templates_test.go | 303 ++++++++++++++++++++++ 2 files changed, 561 insertions(+) create mode 100644 backend/internal/frames/templates.go create mode 100644 backend/internal/frames/templates_test.go diff --git a/backend/internal/frames/templates.go b/backend/internal/frames/templates.go new file mode 100644 index 0000000..858f2b3 --- /dev/null +++ b/backend/internal/frames/templates.go @@ -0,0 +1,258 @@ +package frames + +import ( + "bytes" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// This file implements Frame templates: an authoring affordance local to this +// implementation of the Frame Spec. A template is NOT a Frame and NOT a spec +// concept. It is copied once when a Frame is created and has no further +// relationship to that Frame: nothing is recorded on the Frame, and editing or +// deleting a template cannot affect anything already made from it. The +// reasoning, including why templates are not modelled as Frames, is in +// docs/adr/0001-frame-templates-are-not-frames.md. + +// Requirement is how strongly a template wants a slot filled. It is stricter +// than the Frame schema, which requires no slot at all: an org uses it to hold +// its own Frames to a house standard the spec does not impose. +type Requirement int + +const ( + // RequirementOptional is the default for any slot a template omits. + RequirementOptional Requirement = iota + // RequirementRecommended is never enforced. It tells the authoring surfaces + // to offer the section, not to insist on it. + RequirementRecommended + // RequirementRequired refuses the create-from-template publish while the + // slot is empty. + RequirementRequired +) + +func (r Requirement) String() string { + switch r { + case RequirementRecommended: + return "recommended" + case RequirementRequired: + return "required" + default: + return "optional" + } +} + +// UnmarshalYAML accepts the level names the built-in template files use. +// An unknown value is an error rather than a silent fall back to optional: a +// typo in a level would otherwise quietly drop a requirement its author meant +// to impose, and the whole point of the field is to be a control. +func (r *Requirement) UnmarshalYAML(n *yaml.Node) error { + var s string + if err := n.Decode(&s); err != nil { + return err + } + switch strings.ToLower(strings.TrimSpace(s)) { + case "optional": + *r = RequirementOptional + case "recommended": + *r = RequirementRecommended + case "required": + *r = RequirementRequired + default: + return fmt.Errorf("unknown requirement level %q: want optional, recommended, or required", s) + } + return nil +} + +// MarshalYAML writes the level by name, so a definition file round-trips as +// something a person would want to edit. +func (r Requirement) MarshalYAML() (any, error) { return r.String(), nil } + +// FieldRule is a template's expectation for one slot: how strongly the slot is +// wanted, and what belongs in it. Note carries the template author's own +// guidance, which is more use than the generic per-slot hints the surfaces +// would otherwise show, and is the question an MCP client asks when it runs an +// authoring interview (see issue #61). +type FieldRule struct { + Level Requirement `yaml:"level"` + Note string `yaml:"note,omitempty"` +} + +// Prefill is the content a template seeds into a new Frame. It is a strict +// subset of Doc on purpose: a template seeds content and must never dictate a +// Frame's identity, so name, description, and version are not representable. +type Prefill struct { + Slots Slots + Extends []ExtendRef +} + +// prefillExtendRef is a narrower view of ExtendRef used only for marshalling: +// prefill encoding omits version to keep templates' extend specs minimal. +// Decoding through Parse preserves version normally. +type prefillExtendRef struct { + Ref string `yaml:"ref"` +} + +// prefillDoc is the serialized shape of a Prefill. Decoding goes through Parse, +// which yields a full Doc and so catches identity fields a template must not +// set; encoding uses this narrower type so those keys are omitted entirely +// rather than emitted empty. The asymmetry is deliberate: it means a blob this +// package writes is always one it will accept back. +type prefillDoc struct { + Extends []prefillExtendRef `yaml:"extends,omitempty"` + Slots Slots `yaml:"slots"` +} + +// Template is one starter definition. Built-ins are compiled in; org templates +// are rows in frame_templates. Nothing downstream distinguishes them beyond the +// ID prefix, which only the RPC layer reads (to refuse mutations on a built-in). +type Template struct { + ID string + Title string + Description string + Prefill Prefill + FieldRules map[string]FieldRule // slot key from SlotTable -> rule +} + +// BuiltinPrefix marks the IDs of templates compiled into the binary. They have +// no org and no row, so they cannot be edited or deleted. +const BuiltinPrefix = "builtin:" + +// BlankTemplateID is the "start from nothing" option. It is a real template with +// no prefill and no rules rather than a special case in the picker, which keeps +// the seed path uniform: starting blank is not a branch. +const BlankTemplateID = BuiltinPrefix + "blank" + +// IsBuiltin reports whether id names a template compiled into the binary. +func IsBuiltin(id string) bool { return strings.HasPrefix(id, BuiltinPrefix) } + +// slotByKey resolves a slot key to its descriptor. SlotTable is ten entries and +// this runs at load and validation time only, so a linear scan is the right +// cost; SlotByHeading next door does the same. +func slotByKey(key string) (SlotDescriptor, bool) { + for _, d := range SlotTable { + if d.Key == key { + return d, true + } + } + return SlotDescriptor{}, false +} + +// slotHasContent reports whether a slot carries anything an author would call +// content. Emptiness is defined here rather than trusting the frontend's +// sectionHasContent: this is what the publish path checks against, and a client +// computing it differently must not be able to slip a blank required section +// past the check. +func slotHasContent(d SlotDescriptor, slots *Slots) bool { + switch d.Kind { + case SlotTerms: + // A term with no definition is not content: it is the start of one. + for _, term := range slots.Terminology { + if strings.TrimSpace(term.Term) != "" && strings.TrimSpace(term.Definition) != "" { + return true + } + } + return false + case SlotList: + for _, item := range slots.List(d.Key) { + if strings.TrimSpace(item) != "" { + return true + } + } + return false + case SlotProse: + return strings.TrimSpace(slots.Prose(d.Key)) != "" + } + return false +} + +// Check reports the slots this template requires that doc leaves empty. +// +// Pure and side-effect free: the publish path merges these with the schema's own +// violations so an author sees every problem at once, instead of fixing the +// schema's and only then discovering the template's. +// +// Only RequirementRequired produces a violation. Recommended is guidance for the +// authoring surfaces and is deliberately not enforced, and this runs on create +// only - see the ADR on why requirements are an authoring aid rather than +// ongoing governance. +func (t *Template) Check(doc *Doc) []FieldError { + if t == nil || len(t.FieldRules) == 0 { + return nil + } + var errs []FieldError + // Walk SlotTable rather than ranging the map, so violations come out in + // schema order: that matches how Validate reports and how the form lays its + // sections out, and map order would make the output nondeterministic. + for _, d := range SlotTable { + rule, ok := t.FieldRules[d.Key] + if !ok || rule.Level != RequirementRequired { + continue + } + if slotHasContent(d, &doc.Slots) { + continue + } + msg := fmt.Sprintf("required by the %q template", t.Title) + if rule.Note != "" { + msg += ": " + rule.Note + } + errs = append(errs, FieldError{Path: "slots." + d.Key, Message: msg}) + } + return errs +} + +// ParsePrefill decodes a prefill blob: the canonical YAML subset carrying slots +// and suggested parents. +// +// It rejects every identity field. Rejecting beats ignoring: a template author +// who set `name` meant something by it, and silently dropping it would surprise +// them the first time a Frame came out with a different name. Excludes are +// refused for the same reason - an exclusion is a statement about one Frame's +// relationship to another, not something a starter can decide in advance. +func ParsePrefill(content []byte) (Prefill, error) { + doc, err := Parse(content) + if err != nil { + return Prefill{}, err + } + for _, f := range []struct { + field string + set bool + }{ + {"name", doc.Name != ""}, + {"description", doc.Description != ""}, + {"version", doc.Version != ""}, + {"visibility", doc.Visibility != ""}, + {"scope", doc.Scope != ""}, + {"maintainer", doc.Maintainer != ""}, + } { + if f.set { + return Prefill{}, fmt.Errorf( + "prefill must not set %s: a template seeds content, not identity", f.field) + } + } + if len(doc.Excludes) > 0 { + return Prefill{}, fmt.Errorf( + "prefill must not set excludes: an exclusion belongs to a Frame, not to the template it started from") + } + return Prefill{Slots: doc.Slots, Extends: doc.Extends}, nil +} + +// MarshalPrefill encodes a Prefill to the canonical YAML subset the API carries +// and the CLI scaffold writes. +func MarshalPrefill(p Prefill) ([]byte, error) { + extends := make([]prefillExtendRef, len(p.Extends)) + for i, e := range p.Extends { + extends[i] = prefillExtendRef{Ref: e.Ref} + } + return yaml.Marshal(prefillDoc{Extends: extends, Slots: p.Slots}) +} + +// unmarshalStrict decodes YAML into v, rejecting unknown keys. Template +// definition files are ours and their key set is fixed, so a typo must fail +// loudly rather than leave a rule silently unset. +func unmarshalStrict(b []byte, v any) error { + dec := yaml.NewDecoder(bytes.NewReader(b)) + dec.KnownFields(true) + return dec.Decode(v) +} diff --git a/backend/internal/frames/templates_test.go b/backend/internal/frames/templates_test.go new file mode 100644 index 0000000..05d646a --- /dev/null +++ b/backend/internal/frames/templates_test.go @@ -0,0 +1,303 @@ +package frames + +import ( + "strings" + "testing" +) + +func TestRequirementUnmarshalYAML(t *testing.T) { + tests := []struct { + name string + yaml string + want Requirement + wantErr string + }{ + {name: "optional", yaml: "level: optional", want: RequirementOptional}, + {name: "recommended", yaml: "level: recommended", want: RequirementRecommended}, + {name: "required", yaml: "level: required", want: RequirementRequired}, + {name: "case and space insensitive", yaml: "level: \" Required \"", want: RequirementRequired}, + {name: "unknown level is an error", yaml: "level: mandatory", wantErr: "unknown requirement level"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got FieldRule + err := unmarshalStrict([]byte(tt.yaml), &got) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Level != tt.want { + t.Errorf("level = %v, want %v", got.Level, tt.want) + } + }) + } +} + +func TestTemplateCheck(t *testing.T) { + tests := []struct { + name string + title string + rules map[string]FieldRule + slots Slots + wantPaths []string + wantMsg string + }{ + { + name: "required prose slot empty is a violation", + rules: map[string]FieldRule{"style": {Level: RequirementRequired}}, + slots: Slots{}, + wantPaths: []string{"slots.style"}, + }, + { + name: "required prose slot of only whitespace is empty", + rules: map[string]FieldRule{"style": {Level: RequirementRequired}}, + slots: Slots{Style: " \n\t "}, + wantPaths: []string{"slots.style"}, + }, + { + name: "required prose slot filled passes", + rules: map[string]FieldRule{"style": {Level: RequirementRequired}}, + slots: Slots{Style: "Plain, direct sentences."}, + wantPaths: nil, + }, + { + name: "required list slot of only blank entries is empty", + rules: map[string]FieldRule{"rules": {Level: RequirementRequired}}, + slots: Slots{Rules: []string{"", " "}}, + wantPaths: []string{"slots.rules"}, + }, + { + name: "required list slot with one real entry passes", + rules: map[string]FieldRule{"rules": {Level: RequirementRequired}}, + slots: Slots{Rules: []string{"", "Never ship on a Friday."}}, + wantPaths: nil, + }, + { + name: "required terms slot needs a definition too", + rules: map[string]FieldRule{"terminology": {Level: RequirementRequired}}, + slots: Slots{Terminology: []Term{{Term: "Frame", Definition: " "}}}, + wantPaths: []string{"slots.terminology"}, + }, + { + name: "required terms slot with a complete entry passes", + rules: map[string]FieldRule{"terminology": {Level: RequirementRequired}}, + slots: Slots{Terminology: []Term{{Term: "Frame", Definition: "A scoped context artifact."}}}, + wantPaths: nil, + }, + { + name: "recommended is never enforced", + rules: map[string]FieldRule{"style": {Level: RequirementRecommended}}, + slots: Slots{}, + wantPaths: nil, + }, + { + name: "optional is never enforced", + rules: map[string]FieldRule{"style": {Level: RequirementOptional}}, + slots: Slots{}, + wantPaths: nil, + }, + { + name: "a slot with no rule is never enforced", + rules: map[string]FieldRule{}, + slots: Slots{}, + wantPaths: nil, + }, + { + name: "violations come out in SlotTable order, not map order", + rules: map[string]FieldRule{ + "business_process": {Level: RequirementRequired}, + "terminology": {Level: RequirementRequired}, + "style": {Level: RequirementRequired}, + }, + slots: Slots{}, + wantPaths: []string{"slots.terminology", "slots.style", "slots.business_process"}, + }, + { + name: "the rule's note is carried into the message", + title: "Domain Vocabulary", + rules: map[string]FieldRule{"terminology": {Level: RequirementRequired, Note: "One entry per term of art."}}, + slots: Slots{}, + wantPaths: []string{"slots.terminology"}, + wantMsg: `required by the "Domain Vocabulary" template: One entry per term of art.`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpl := &Template{ID: "builtin:test", Title: tt.title, FieldRules: tt.rules} + got := tmpl.Check(&Doc{Slots: tt.slots}) + if len(got) != len(tt.wantPaths) { + t.Fatalf("got %d violations %v, want %d %v", len(got), got, len(tt.wantPaths), tt.wantPaths) + } + for i, want := range tt.wantPaths { + if got[i].Path != want { + t.Errorf("violation %d path = %q, want %q", i, got[i].Path, want) + } + } + if tt.wantMsg != "" && got[0].Message != tt.wantMsg { + t.Errorf("message = %q, want %q", got[0].Message, tt.wantMsg) + } + }) + } +} + +func TestTemplateCheckNilTemplateIsNoOp(t *testing.T) { + var tmpl *Template + if got := tmpl.Check(&Doc{}); got != nil { + t.Errorf("nil template produced %v, want nil", got) + } +} + +func TestParsePrefill(t *testing.T) { + tests := []struct { + name string + yaml string + wantErr string + wantTerm string + wantExt int + }{ + { + name: "slots and extends are kept", + yaml: "extends:\n - ref: acme/base\n version: 1.0.0\nslots:\n terminology:\n - term: Frame\n definition: A scoped context artifact.\n", + wantTerm: "Frame", + wantExt: 1, + }, + {name: "name is rejected", yaml: "name: brand-voice\nslots: {}\n", wantErr: "must not set name"}, + {name: "version is rejected", yaml: "version: 1.0.0\nslots: {}\n", wantErr: "must not set version"}, + {name: "description is rejected", yaml: "description: hello\nslots: {}\n", wantErr: "must not set description"}, + {name: "visibility is rejected", yaml: "visibility: internal\nslots: {}\n", wantErr: "must not set visibility"}, + {name: "scope is rejected", yaml: "scope: team\nslots: {}\n", wantErr: "must not set scope"}, + {name: "maintainer is rejected", yaml: "maintainer: a@b.c\nslots: {}\n", wantErr: "must not set maintainer"}, + {name: "excludes is rejected", yaml: "excludes:\n - acme/other\nslots: {}\n", wantErr: "must not set excludes"}, + {name: "an unknown key is rejected", yaml: "colour: red\nslots: {}\n", wantErr: "parse frame yaml"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParsePrefill([]byte(tt.yaml)) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got.Extends) != tt.wantExt { + t.Errorf("extends = %d, want %d", len(got.Extends), tt.wantExt) + } + if tt.wantTerm != "" { + if len(got.Slots.Terminology) != 1 || got.Slots.Terminology[0].Term != tt.wantTerm { + t.Errorf("terminology = %v, want one entry %q", got.Slots.Terminology, tt.wantTerm) + } + } + }) + } +} + +func TestPrefillRoundTripsWithoutIdentityKeys(t *testing.T) { + in := Prefill{ + Slots: Slots{Style: "Plain sentences.", Rules: []string{"No em dashes."}}, + Extends: []ExtendRef{{Ref: "acme/base", Version: "1.0.0"}}, + } + b, err := MarshalPrefill(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Identity keys must not be emitted at all: ParsePrefill rejects them, so a + // blob this function produced has to survive being read back. + for _, key := range []string{"name:", "version:", "description:", "visibility:"} { + if strings.Contains(string(b), key) { + t.Errorf("marshalled prefill contains %q:\n%s", key, b) + } + } + out, err := ParsePrefill(b) + if err != nil { + t.Fatalf("round trip rejected its own output: %v\n%s", err, b) + } + if out.Slots.Style != in.Slots.Style || len(out.Extends) != 1 { + t.Errorf("round trip lost content: %+v", out) + } +} + +// TestSlotHasContentCoversEverySlot is the reflective guard the spec calls for, +// mirroring the ones in backend/internal/mcp/resources_test.go. It walks +// SlotTable so that adding a slot without teaching slotHasContent about its kind +// fails here. Without it, a new slot kind would fall through the switch, always +// read as empty, and make a `required` rule on it permanently unsatisfiable - +// silently, from the author's point of view. +func TestSlotHasContentCoversEverySlot(t *testing.T) { + for _, d := range SlotTable { + t.Run(d.Key, func(t *testing.T) { + // Empty must read as empty. + var empty Slots + if slotHasContent(d, &empty) { + t.Errorf("an empty %s slot reads as having content", d.Key) + } + // A populated instance of this slot's kind must read as content. + filled := &Slots{} + switch d.Kind { + case SlotTerms: + filled.Terminology = []Term{{Term: "Frame", Definition: "A scoped context artifact."}} + case SlotList: + filled.SetList(d.Key, []string{"An entry."}) + case SlotProse: + filled.SetProse(d.Key, "Some prose.") + default: + t.Fatalf("slot %s has kind %v, which slotHasContent does not handle; add a case", d.Key, d.Kind) + } + if !slotHasContent(d, filled) { + t.Errorf("a populated %s slot reads as empty; slotHasContent is missing a case for kind %v", d.Key, d.Kind) + } + }) + } +} + +func TestIsBuiltin(t *testing.T) { + tests := []struct { + id string + want bool + }{ + {id: "builtin:blank", want: true}, + {id: "builtin:domain-vocabulary", want: true}, + {id: "01J000000000000000000000AB", want: false}, + {id: "", want: false}, + {id: "notbuiltin:x", want: false}, + } + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + if got := IsBuiltin(tt.id); got != tt.want { + t.Errorf("IsBuiltin(%q) = %v, want %v", tt.id, got, tt.want) + } + }) + } +} + +func TestSlotByKey(t *testing.T) { + tests := []struct { + key string + wantOK bool + wantKey string + }{ + {key: "terminology", wantOK: true, wantKey: "terminology"}, + {key: "style", wantOK: true, wantKey: "style"}, + {key: "business_process", wantOK: true, wantKey: "business_process"}, + {key: "unknown", wantOK: false}, + {key: "", wantOK: false}, + } + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + got, ok := slotByKey(tt.key) + if ok != tt.wantOK { + t.Errorf("slotByKey(%q) ok = %v, want %v", tt.key, ok, tt.wantOK) + } + if ok && got.Key != tt.wantKey { + t.Errorf("slotByKey(%q) key = %q, want %q", tt.key, got.Key, tt.wantKey) + } + }) + } +} From f0d98b2a20b52c1c9c4483925651fc2e36ee5dc0 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:01:40 +0200 Subject: [PATCH 02/43] fix: restore MarshalPrefill to preserve pinned parent version --- backend/internal/frames/templates.go | 17 +++-------------- backend/internal/frames/templates_test.go | 21 ++++++++++++++++----- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/backend/internal/frames/templates.go b/backend/internal/frames/templates.go index 858f2b3..9a06c8d 100644 --- a/backend/internal/frames/templates.go +++ b/backend/internal/frames/templates.go @@ -87,21 +87,14 @@ type Prefill struct { Extends []ExtendRef } -// prefillExtendRef is a narrower view of ExtendRef used only for marshalling: -// prefill encoding omits version to keep templates' extend specs minimal. -// Decoding through Parse preserves version normally. -type prefillExtendRef struct { - Ref string `yaml:"ref"` -} - // prefillDoc is the serialized shape of a Prefill. Decoding goes through Parse, // which yields a full Doc and so catches identity fields a template must not // set; encoding uses this narrower type so those keys are omitted entirely // rather than emitted empty. The asymmetry is deliberate: it means a blob this // package writes is always one it will accept back. type prefillDoc struct { - Extends []prefillExtendRef `yaml:"extends,omitempty"` - Slots Slots `yaml:"slots"` + Extends []ExtendRef `yaml:"extends,omitempty"` + Slots Slots `yaml:"slots"` } // Template is one starter definition. Built-ins are compiled in; org templates @@ -241,11 +234,7 @@ func ParsePrefill(content []byte) (Prefill, error) { // MarshalPrefill encodes a Prefill to the canonical YAML subset the API carries // and the CLI scaffold writes. func MarshalPrefill(p Prefill) ([]byte, error) { - extends := make([]prefillExtendRef, len(p.Extends)) - for i, e := range p.Extends { - extends[i] = prefillExtendRef{Ref: e.Ref} - } - return yaml.Marshal(prefillDoc{Extends: extends, Slots: p.Slots}) + return yaml.Marshal(prefillDoc{Extends: p.Extends, Slots: p.Slots}) } // unmarshalStrict decodes YAML into v, rejecting unknown keys. Template diff --git a/backend/internal/frames/templates_test.go b/backend/internal/frames/templates_test.go index 05d646a..a24df3f 100644 --- a/backend/internal/frames/templates_test.go +++ b/backend/internal/frames/templates_test.go @@ -1,6 +1,7 @@ package frames import ( + "regexp" "strings" "testing" ) @@ -210,17 +211,27 @@ func TestPrefillRoundTripsWithoutIdentityKeys(t *testing.T) { } // Identity keys must not be emitted at all: ParsePrefill rejects them, so a // blob this function produced has to survive being read back. - for _, key := range []string{"name:", "version:", "description:", "visibility:"} { - if strings.Contains(string(b), key) { - t.Errorf("marshalled prefill contains %q:\n%s", key, b) + // + // Anchored to the start of a line. A bare substring check would also match + // the nested `extends[].version` key, which is legitimate and must be kept: + // that field is the pinned parent version, and Validate rejects an extends + // ref without one. + for _, key := range []string{"name:", "version:", "description:", "visibility:", "scope:", "maintainer:", "excludes:"} { + if regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key)).Match(b) { + t.Errorf("marshalled prefill emits top-level %q:\n%s", key, b) } } out, err := ParsePrefill(b) if err != nil { t.Fatalf("round trip rejected its own output: %v\n%s", err, b) } - if out.Slots.Style != in.Slots.Style || len(out.Extends) != 1 { - t.Errorf("round trip lost content: %+v", out) + if out.Slots.Style != in.Slots.Style { + t.Errorf("round trip lost slot content: %+v", out.Slots) + } + // The pin specifically: dropping the version would leave every Frame seeded + // from a template with suggested parents unpublishable. + if len(out.Extends) != 1 || out.Extends[0].Ref != "acme/base" || out.Extends[0].Version != "1.0.0" { + t.Errorf("round trip lost the pinned parent: %+v", out.Extends) } } From abcf6c82c9ea85deb249b145bb1291cdaaee514e Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:07:08 +0200 Subject: [PATCH 03/43] feat(frames): ship built-in Frame templates, validated at boot --- backend/internal/frames/builtins.go | 130 ++++++++++++++++++ backend/internal/frames/builtins/blank.yaml | 5 + .../internal/frames/builtins/brand-voice.yaml | 13 ++ .../frames/builtins/business-process.yaml | 13 ++ .../frames/builtins/domain-vocabulary.yaml | 10 ++ .../frames/builtins/engineering-norms.yaml | 13 ++ .../frames/builtins/product-context.yaml | 16 +++ backend/internal/frames/builtins_test.go | 106 ++++++++++++++ 8 files changed, 306 insertions(+) create mode 100644 backend/internal/frames/builtins.go create mode 100644 backend/internal/frames/builtins/blank.yaml create mode 100644 backend/internal/frames/builtins/brand-voice.yaml create mode 100644 backend/internal/frames/builtins/business-process.yaml create mode 100644 backend/internal/frames/builtins/domain-vocabulary.yaml create mode 100644 backend/internal/frames/builtins/engineering-norms.yaml create mode 100644 backend/internal/frames/builtins/product-context.yaml create mode 100644 backend/internal/frames/builtins_test.go diff --git a/backend/internal/frames/builtins.go b/backend/internal/frames/builtins.go new file mode 100644 index 0000000..77e4741 --- /dev/null +++ b/backend/internal/frames/builtins.go @@ -0,0 +1,130 @@ +package frames + +import ( + "embed" + "fmt" + "sort" + "strings" +) + +// Built-in templates are compiled in rather than seeded into the database. +// Seeding them per org would mean N rows per org and reconciliation on every +// upgrade, and a shared "system org" is unreadable by design: rbac.Can opens +// with a cross-org deny, so no caller could see one. See ADR 0001. +// +// The definition files are our own format, deliberately not .frame.md: that is +// the external interchange shape framemd.go exists to translate, and a +// frame-spec revision moving a heading must not be able to break a starter +// document that has no reason to care. + +//go:embed builtins/*.yaml +var builtinFS embed.FS + +// builtinFile is the on-disk shape of one definition. There is no `prefill` +// key: built-ins carry rules and notes only, because shipping example prose +// that every author has to delete makes the blank page worse rather than +// better. Prefill is for org templates. Add the key when a built-in genuinely +// needs it. +type builtinFile struct { + ID string `yaml:"id"` + Title string `yaml:"title"` + Description string `yaml:"description"` + FieldRules map[string]FieldRule `yaml:"field_rules,omitempty"` +} + +var builtins []Template + +func init() { + loaded, err := loadBuiltins() + if err != nil { + // A malformed starter must fail at startup, not at a user's first click + // on the picker. The files are compiled in, so a failure here is a build + // defect with no runtime recovery - the same reasoning as the + // package-level regexp.MustCompile calls in validate.go. + panic("frames: loading built-in templates: " + err.Error()) + } + builtins = loaded +} + +func loadBuiltins() ([]Template, error) { + entries, err := builtinFS.ReadDir("builtins") + if err != nil { + return nil, err + } + out := make([]Template, 0, len(entries)) + seen := map[string]bool{} + for _, entry := range entries { + name := entry.Name() + b, err := builtinFS.ReadFile("builtins/" + name) + if err != nil { + return nil, err + } + var f builtinFile + if err := unmarshalStrict(b, &f); err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + if !IsBuiltin(f.ID) { + return nil, fmt.Errorf("%s: id %q must start with %q", name, f.ID, BuiltinPrefix) + } + if seen[f.ID] { + return nil, fmt.Errorf("%s: duplicate id %q", name, f.ID) + } + seen[f.ID] = true + if strings.TrimSpace(f.Title) == "" { + return nil, fmt.Errorf("%s: title must not be empty", name) + } + if strings.TrimSpace(f.Description) == "" { + return nil, fmt.Errorf("%s: description must not be empty", name) + } + for key, rule := range f.FieldRules { + if _, ok := slotByKey(key); !ok { + return nil, fmt.Errorf("%s: field_rules names unknown slot %q", name, key) + } + if strings.TrimSpace(rule.Note) == "" { + return nil, fmt.Errorf("%s: field_rules[%s] has no note", name, key) + } + } + out = append(out, Template{ + ID: f.ID, Title: f.Title, Description: f.Description, FieldRules: f.FieldRules, + }) + } + // Blank leads: it is the "no template" escape hatch and must be easy to + // find. Everything else keeps filename order, which is alphabetical by + // title. SliceStable so that order is preserved rather than reshuffled. + sort.SliceStable(out, func(i, j int) bool { + return out[i].ID == BlankTemplateID && out[j].ID != BlankTemplateID + }) + return out, nil +} + +// BuiltinTemplates returns the templates compiled into the binary, Blank first. +// Each call gets its own copy, rules map included: every request reads these, so +// a caller must not be able to mutate the package's copy. +func BuiltinTemplates() []Template { + out := make([]Template, 0, len(builtins)) + for _, tmpl := range builtins { + out = append(out, copyTemplate(tmpl)) + } + return out +} + +// BuiltinTemplate looks one up by ID, reporting whether it exists. +func BuiltinTemplate(id string) (Template, bool) { + for _, tmpl := range builtins { + if tmpl.ID == id { + return copyTemplate(tmpl), true + } + } + return Template{}, false +} + +func copyTemplate(in Template) Template { + out := in + if in.FieldRules != nil { + out.FieldRules = make(map[string]FieldRule, len(in.FieldRules)) + for key, rule := range in.FieldRules { + out.FieldRules[key] = rule + } + } + return out +} diff --git a/backend/internal/frames/builtins/blank.yaml b/backend/internal/frames/builtins/blank.yaml new file mode 100644 index 0000000..ca5b271 --- /dev/null +++ b/backend/internal/frames/builtins/blank.yaml @@ -0,0 +1,5 @@ +# Blank is a real template rather than a special case in the picker, so that +# starting from nothing takes the same code path as starting from a starter. +id: builtin:blank +title: Blank +description: Start from an empty document and add only the sections you need. diff --git a/backend/internal/frames/builtins/brand-voice.yaml b/backend/internal/frames/builtins/brand-voice.yaml new file mode 100644 index 0000000..3483312 --- /dev/null +++ b/backend/internal/frames/builtins/brand-voice.yaml @@ -0,0 +1,13 @@ +id: builtin:brand-voice +title: Brand Voice and Style +description: How your organization sounds in writing, and the words it does and does not use. +field_rules: + style: + level: required + note: Voice, tone, and formatting. Say what to do, not only what to avoid, and give an example of each. + terminology: + level: recommended + note: Product and brand names with their correct spelling and casing, plus any word you deliberately avoid. + rules: + level: recommended + note: Hard constraints, one per entry, that an assistant must never break when writing as your organization. diff --git a/backend/internal/frames/builtins/business-process.yaml b/backend/internal/frames/builtins/business-process.yaml new file mode 100644 index 0000000..427558e --- /dev/null +++ b/backend/internal/frames/builtins/business-process.yaml @@ -0,0 +1,13 @@ +id: builtin:business-process +title: Business Process +description: How a piece of work moves through your organization, stage by stage. +field_rules: + business_process: + level: required + note: The stages work passes through, who owns each, and what has to be true before it moves on. + terminology: + level: recommended + note: Names for stages, artifacts, and roles, so an assistant uses your words rather than generic ones. + goals: + level: recommended + note: What a good outcome looks like at the end of this process. diff --git a/backend/internal/frames/builtins/domain-vocabulary.yaml b/backend/internal/frames/builtins/domain-vocabulary.yaml new file mode 100644 index 0000000..370ff76 --- /dev/null +++ b/backend/internal/frames/builtins/domain-vocabulary.yaml @@ -0,0 +1,10 @@ +id: builtin:domain-vocabulary +title: Domain Vocabulary +description: The terms of art your organization uses and what they actually mean here. +field_rules: + terminology: + level: required + note: One entry per term. Define it the way an insider would explain it to a new colleague, not the way a dictionary would. + rules: + level: recommended + note: Usage constraints worth stating, such as a term that is often confused with another. diff --git a/backend/internal/frames/builtins/engineering-norms.yaml b/backend/internal/frames/builtins/engineering-norms.yaml new file mode 100644 index 0000000..fce47ee --- /dev/null +++ b/backend/internal/frames/builtins/engineering-norms.yaml @@ -0,0 +1,13 @@ +id: builtin:engineering-norms +title: Engineering Norms +description: How your team expects software to be built, reviewed, and shipped. +field_rules: + norms: + level: required + note: "Ways of working: review expectations, testing standards, what \"done\" means, and how work gets committed." + rules: + level: recommended + note: "Hard constraints, one per entry, that must hold regardless of context." + architecture: + level: recommended + note: "The structure worth knowing before changing anything: services, boundaries, and what depends on what." diff --git a/backend/internal/frames/builtins/product-context.yaml b/backend/internal/frames/builtins/product-context.yaml new file mode 100644 index 0000000..ae6763c --- /dev/null +++ b/backend/internal/frames/builtins/product-context.yaml @@ -0,0 +1,16 @@ +id: builtin:product-context +title: Product Context +description: What you are building, who it is for, and what success looks like. +field_rules: + goals: + level: required + note: What good outcomes look like for work done in this product, and how you would know you got one. + architecture: + level: recommended + note: The system's shape at the level a newcomer needs before touching it. + terminology: + level: recommended + note: Product-specific names for features, objects, and user types. + business_process: + level: recommended + note: How a change reaches users, if that is not obvious. diff --git a/backend/internal/frames/builtins_test.go b/backend/internal/frames/builtins_test.go new file mode 100644 index 0000000..9aaf9fd --- /dev/null +++ b/backend/internal/frames/builtins_test.go @@ -0,0 +1,106 @@ +package frames + +import "testing" + +func TestBuiltinTemplatesLoad(t *testing.T) { + got := BuiltinTemplates() + if len(got) < 6 { + t.Fatalf("loaded %d built-ins, want at least 6", len(got)) + } + // Blank leads the picker: it is the escape hatch, and burying it among the + // starters makes it hard to find. + if got[0].ID != BlankTemplateID { + t.Errorf("first built-in is %q, want %q", got[0].ID, BlankTemplateID) + } + if len(got[0].FieldRules) != 0 { + t.Errorf("blank carries %d rules, want none", len(got[0].FieldRules)) + } +} + +func TestBuiltinTemplatesAreWellFormed(t *testing.T) { + seen := map[string]bool{} + for _, tmpl := range BuiltinTemplates() { + t.Run(tmpl.ID, func(t *testing.T) { + if !IsBuiltin(tmpl.ID) { + t.Errorf("id %q does not start with %q", tmpl.ID, BuiltinPrefix) + } + if seen[tmpl.ID] { + t.Errorf("duplicate id %q", tmpl.ID) + } + seen[tmpl.ID] = true + if tmpl.Title == "" { + t.Error("title is empty") + } + if tmpl.Description == "" { + t.Error("description is empty") + } + // A built-in carrying prefill would mean shipping placeholder prose + // authors have to delete. See the note in builtins.go. + if len(tmpl.Prefill.Slots.Rules) > 0 || tmpl.Prefill.Slots.Style != "" || + len(tmpl.Prefill.Slots.Terminology) > 0 || len(tmpl.Prefill.Extends) > 0 { + t.Error("built-in carries prefill, which built-ins deliberately do not") + } + for key, rule := range tmpl.FieldRules { + if _, ok := slotByKey(key); !ok { + t.Errorf("rule names unknown slot %q", key) + } + // A rule with no note is a missed opportunity: the note is what + // the seeded form shows and what an MCP interview asks. + if rule.Note == "" { + t.Errorf("rule for %q has no note", key) + } + } + }) + } +} + +func TestBuiltinTemplateLookup(t *testing.T) { + tests := []struct { + id string + found bool + }{ + {id: BlankTemplateID, found: true}, + {id: "builtin:domain-vocabulary", found: true}, + {id: "builtin:nope", found: false}, + {id: "01J000000000000000000000AB", found: false}, + } + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + got, ok := BuiltinTemplate(tt.id) + if ok != tt.found { + t.Fatalf("found = %v, want %v", ok, tt.found) + } + if ok && got.ID != tt.id { + t.Errorf("returned id %q, want %q", got.ID, tt.id) + } + }) + } +} + +func TestBuiltinTemplatesAreCopiedPerCall(t *testing.T) { + // Every request reads these, so a caller must not be able to mutate the + // package's own copy. + first := BuiltinTemplates() + target := "" + for _, tmpl := range first { + if len(tmpl.FieldRules) > 0 { + target = tmpl.ID + for key := range tmpl.FieldRules { + tmpl.FieldRules[key] = FieldRule{Level: RequirementOptional, Note: "clobbered"} + } + break + } + } + if target == "" { + t.Fatal("no built-in has rules, so this test proves nothing") + } + again, ok := BuiltinTemplate(target) + if !ok { + t.Fatalf("%s vanished", target) + } + for key, rule := range again.FieldRules { + if rule.Note == "clobbered" { + t.Fatalf("mutating a returned template changed the package copy at %q", key) + } + } +} From 022627bc2544beb7b5fd7633e3daf811c5bcc976 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:18:00 +0200 Subject: [PATCH 04/43] feat(store): add frame template persistence to Repository and the in-memory fake --- backend/internal/store/memory.go | 116 ++++++++++++++-- backend/internal/store/memory_test.go | 185 ++++++++++++++++++++++++++ backend/internal/store/store.go | 37 ++++++ 3 files changed, 324 insertions(+), 14 deletions(-) diff --git a/backend/internal/store/memory.go b/backend/internal/store/memory.go index 270fd0e..27bdfb7 100644 --- a/backend/internal/store/memory.go +++ b/backend/internal/store/memory.go @@ -2,6 +2,7 @@ package store import ( "context" + "sort" "strings" "sync" @@ -10,14 +11,15 @@ import ( // Memory is an in-memory Repository for development and tests. type Memory struct { - mu sync.RWMutex - orgs map[string]*framesv1.Org // id -> org - slugToOrg map[string]string // slug -> id - memberships []*framesv1.Membership // active + pending; active rows have non-empty UserSub - frames map[string]*framesv1.Frame // id -> frame - keyToFrame map[string]string // orgID+"/"+name -> id - versions map[string]*frameVersionRow // frameID+"@"+version - grants map[string][]Grant // frameID -> grants + mu sync.RWMutex + orgs map[string]*framesv1.Org // id -> org + slugToOrg map[string]string // slug -> id + memberships []*framesv1.Membership // active + pending; active rows have non-empty UserSub + frames map[string]*framesv1.Frame // id -> frame + keyToFrame map[string]string // orgID+"/"+name -> id + versions map[string]*frameVersionRow // frameID+"@"+version + grants map[string][]Grant // frameID -> grants + frameTemplates map[string]*FrameTemplate // id -> row } type frameVersionRow struct { @@ -30,12 +32,13 @@ var _ Repository = (*Memory)(nil) func NewMemory() *Memory { return &Memory{ - orgs: map[string]*framesv1.Org{}, - slugToOrg: map[string]string{}, - frames: map[string]*framesv1.Frame{}, - keyToFrame: map[string]string{}, - versions: map[string]*frameVersionRow{}, - grants: map[string][]Grant{}, + orgs: map[string]*framesv1.Org{}, + slugToOrg: map[string]string{}, + frames: map[string]*framesv1.Frame{}, + keyToFrame: map[string]string{}, + versions: map[string]*frameVersionRow{}, + grants: map[string][]Grant{}, + frameTemplates: map[string]*FrameTemplate{}, } } @@ -370,3 +373,88 @@ func (m *Memory) DeleteFrame(_ context.Context, frameID string) error { } return nil } + +// copyFrameTemplate returns an independent copy. Callers mutate what they are +// handed (a handler applies an update to it), so the store must not share its +// own row - the byte slices included. +func copyFrameTemplate(in *FrameTemplate) *FrameTemplate { + out := *in + out.Prefill = append([]byte(nil), in.Prefill...) + out.FieldRules = append([]byte(nil), in.FieldRules...) + return &out +} + +// titleTaken reports whether another row in orgID already uses title. The real +// schema enforces this with UNIQUE (org_id, title); the fake has to enforce it +// too, or a test passes here and fails against SQLite. +func (m *Memory) titleTaken(orgID, title, exceptID string) bool { + for _, row := range m.frameTemplates { + if row.OrgID == orgID && row.Title == title && row.ID != exceptID { + return true + } + } + return false +} + +func (m *Memory) CreateFrameTemplate(_ context.Context, t *FrameTemplate) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.frameTemplates[t.ID]; ok { + return ErrAlreadyExists + } + if m.titleTaken(t.OrgID, t.Title, "") { + return ErrAlreadyExists + } + m.frameTemplates[t.ID] = copyFrameTemplate(t) + return nil +} + +func (m *Memory) UpdateFrameTemplate(_ context.Context, t *FrameTemplate) error { + m.mu.Lock() + defer m.mu.Unlock() + existing, ok := m.frameTemplates[t.ID] + if !ok || existing.OrgID != t.OrgID { + return ErrNotFound + } + if m.titleTaken(t.OrgID, t.Title, t.ID) { + return ErrAlreadyExists + } + m.frameTemplates[t.ID] = copyFrameTemplate(t) + return nil +} + +func (m *Memory) DeleteFrameTemplate(_ context.Context, orgID, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + existing, ok := m.frameTemplates[id] + if !ok || existing.OrgID != orgID { + return ErrNotFound + } + delete(m.frameTemplates, id) + return nil +} + +func (m *Memory) GetFrameTemplate(_ context.Context, orgID, id string) (*FrameTemplate, error) { + m.mu.RLock() + defer m.mu.RUnlock() + row, ok := m.frameTemplates[id] + if !ok || row.OrgID != orgID { + return nil, ErrNotFound + } + return copyFrameTemplate(row), nil +} + +func (m *Memory) ListFrameTemplatesByOrg(_ context.Context, orgID string) ([]*FrameTemplate, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := []*FrameTemplate{} + for _, row := range m.frameTemplates { + if row.OrgID == orgID { + out = append(out, copyFrameTemplate(row)) + } + } + // Deterministic order, so a picker does not reshuffle between requests. + // SQLite orders by title too. + sort.Slice(out, func(i, j int) bool { return out[i].Title < out[j].Title }) + return out, nil +} diff --git a/backend/internal/store/memory_test.go b/backend/internal/store/memory_test.go index d5fabfa..51c3028 100644 --- a/backend/internal/store/memory_test.go +++ b/backend/internal/store/memory_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/nebari-dev/nebari-frames/backend/internal/store" framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1" @@ -240,3 +241,187 @@ func TestMemory_CreateFrameVersionAndGrants(t *testing.T) { t.Run(tt.name, tt.run) } } + +func TestMemoryFrameTemplateCRUD(t *testing.T) { + ctx := context.Background() + m := store.NewMemory() + row := &store.FrameTemplate{ + ID: "t1", OrgID: "org-a", Title: "Brand Voice", Description: "How we sound", + Prefill: []byte("slots: {}\n"), FieldRules: []byte(`{"style":{"level":"required"}}`), + CreatedBy: "user-1", CreatedAt: time.Unix(0, 0).UTC(), UpdatedAt: time.Unix(0, 0).UTC(), + } + if err := m.CreateFrameTemplate(ctx, row); err != nil { + t.Fatalf("create: %v", err) + } + + got, err := m.GetFrameTemplate(ctx, "org-a", "t1") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Title != "Brand Voice" || string(got.FieldRules) != `{"style":{"level":"required"}}` { + t.Errorf("round trip lost data: %+v", got) + } + + // A caller in another org must not see it, and must not learn it exists. + if _, err := m.GetFrameTemplate(ctx, "org-b", "t1"); !errors.Is(err, store.ErrNotFound) { + t.Errorf("cross-org get error = %v, want ErrNotFound", err) + } + rows, err := m.ListFrameTemplatesByOrg(ctx, "org-b") + if err != nil { + t.Fatalf("list org-b: %v", err) + } + if len(rows) != 0 { + t.Errorf("org-b sees %d templates, want 0", len(rows)) + } + + rows, err = m.ListFrameTemplatesByOrg(ctx, "org-a") + if err != nil { + t.Fatalf("list org-a: %v", err) + } + if len(rows) != 1 || rows[0].ID != "t1" { + t.Fatalf("org-a list = %+v, want one row t1", rows) + } + + row.Title = "Brand Voice v2" + row.UpdatedAt = time.Unix(60, 0).UTC() + if err := m.UpdateFrameTemplate(ctx, row); err != nil { + t.Fatalf("update: %v", err) + } + got, err = m.GetFrameTemplate(ctx, "org-a", "t1") + if err != nil { + t.Fatalf("get after update: %v", err) + } + if got.Title != "Brand Voice v2" { + t.Errorf("title = %q, want the updated one", got.Title) + } + + if err := m.DeleteFrameTemplate(ctx, "org-a", "t1"); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := m.GetFrameTemplate(ctx, "org-a", "t1"); !errors.Is(err, store.ErrNotFound) { + t.Errorf("get after delete = %v, want ErrNotFound", err) + } +} + +func TestMemoryFrameTemplateConstraints(t *testing.T) { + ctx := context.Background() + base := func(id, orgID, title string) *store.FrameTemplate { + return &store.FrameTemplate{ID: id, OrgID: orgID, Title: title, Prefill: []byte("slots: {}\n"), FieldRules: []byte("{}")} + } + tests := []struct { + name string + seed []*store.FrameTemplate + op func(*store.Memory) error + wantErr error + }{ + { + name: "duplicate title in the same org is rejected", + seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice")}, + op: func(m *store.Memory) error { + return m.CreateFrameTemplate(context.Background(), base("t2", "org-a", "Brand Voice")) + }, + wantErr: store.ErrAlreadyExists, + }, + { + name: "the same title in a different org is fine", + seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice")}, + op: func(m *store.Memory) error { + return m.CreateFrameTemplate(context.Background(), base("t2", "org-b", "Brand Voice")) + }, + }, + { + name: "duplicate id is rejected", + seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice")}, + op: func(m *store.Memory) error { + return m.CreateFrameTemplate(context.Background(), base("t1", "org-a", "Other")) + }, + wantErr: store.ErrAlreadyExists, + }, + { + name: "renaming onto another row's title is rejected", + seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice"), base("t2", "org-a", "Engineering Norms")}, + op: func(m *store.Memory) error { + return m.UpdateFrameTemplate(context.Background(), base("t2", "org-a", "Brand Voice")) + }, + wantErr: store.ErrAlreadyExists, + }, + { + name: "renaming a row to its own title is fine", + seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice")}, + op: func(m *store.Memory) error { + return m.UpdateFrameTemplate(context.Background(), base("t1", "org-a", "Brand Voice")) + }, + }, + { + name: "updating a missing row is not found", + op: func(m *store.Memory) error { + return m.UpdateFrameTemplate(context.Background(), base("nope", "org-a", "X")) + }, + wantErr: store.ErrNotFound, + }, + { + name: "updating another org's row is not found", + seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice")}, + op: func(m *store.Memory) error { + return m.UpdateFrameTemplate(context.Background(), base("t1", "org-b", "Brand Voice")) + }, + wantErr: store.ErrNotFound, + }, + { + name: "deleting a missing row is not found", + op: func(m *store.Memory) error { return m.DeleteFrameTemplate(context.Background(), "org-a", "nope") }, + wantErr: store.ErrNotFound, + }, + { + name: "deleting another org's row is not found", + seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice")}, + op: func(m *store.Memory) error { return m.DeleteFrameTemplate(context.Background(), "org-b", "t1") }, + wantErr: store.ErrNotFound, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := store.NewMemory() + for _, row := range tt.seed { + if err := m.CreateFrameTemplate(ctx, row); err != nil { + t.Fatalf("seeding %s: %v", row.ID, err) + } + } + err := tt.op(m) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("err = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestMemoryFrameTemplateReturnsCopies(t *testing.T) { + // A handler applying an update mutates the struct it was handed, so the + // store must not be sharing its own row. + ctx := context.Background() + m := store.NewMemory() + if err := m.CreateFrameTemplate(ctx, &store.FrameTemplate{ + ID: "t1", OrgID: "org-a", Title: "Brand Voice", + Prefill: []byte("slots: {}\n"), FieldRules: []byte("{}"), + }); err != nil { + t.Fatalf("create: %v", err) + } + got, err := m.GetFrameTemplate(ctx, "org-a", "t1") + if err != nil { + t.Fatalf("get: %v", err) + } + got.Title = "clobbered" + again, err := m.GetFrameTemplate(ctx, "org-a", "t1") + if err != nil { + t.Fatalf("get again: %v", err) + } + if again.Title != "Brand Voice" { + t.Errorf("mutating the returned row changed the store: %q", again.Title) + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 275ab1a..d440fd3 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -7,6 +7,7 @@ import ( "context" "errors" "strings" + "time" framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1" ) @@ -42,6 +43,31 @@ type ParentEdge struct { OrderIndex int } +// FrameTemplate is one org-authored template row. +// +// A store struct rather than a protobuf message, unlike Frame and Membership: +// the proto is the external representation, and the template model is +// deliberately owned internally with translation at the boundary. Grant and +// ParentEdge above are store structs for the same reason. +// +// Built-in templates are not rows at all. They are compiled into the binary +// (frames.BuiltinTemplates), so nothing in this package knows about them. +// +// Prefill and FieldRules are opaque blobs here. Keeping them as bytes means +// adding a slot to frames.SlotTable never touches this schema, matching how +// frame_versions.content is stored. +type FrameTemplate struct { + ID string + OrgID string + Title string + Description string + Prefill []byte // canonical YAML: slots + extends + FieldRules []byte // JSON: slot key -> {level, note} + CreatedBy string + CreatedAt time.Time + UpdatedAt time.Time +} + // CreateFrameVersionInput carries everything a publish needs to insert // atomically: the frame row (created if new), the version, its inheritance // edges, and its default grants. @@ -86,4 +112,15 @@ type Repository interface { FrameChildren(ctx context.Context, parentFrameID string) ([]*framesv1.Frame, error) DeleteFrame(ctx context.Context, frameID string) error + + // Frame templates. Every accessor takes orgID because org scoping is the + // only access control a template has: making it impossible to ask for a row + // without saying who is asking beats relying on each handler to remember. A + // row belonging to another org reports ErrNotFound rather than a denial, so + // existence does not leak. + CreateFrameTemplate(ctx context.Context, t *FrameTemplate) error + UpdateFrameTemplate(ctx context.Context, t *FrameTemplate) error + DeleteFrameTemplate(ctx context.Context, orgID, id string) error + GetFrameTemplate(ctx context.Context, orgID, id string) (*FrameTemplate, error) + ListFrameTemplatesByOrg(ctx context.Context, orgID string) ([]*FrameTemplate, error) } From 655262c3d2af46daaa8f7073cb599aff14704aec Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:23:54 +0200 Subject: [PATCH 05/43] test(store): make frame template copy test catch slice aliasing --- backend/internal/store/memory_test.go | 41 +++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/backend/internal/store/memory_test.go b/backend/internal/store/memory_test.go index 51c3028..7357945 100644 --- a/backend/internal/store/memory_test.go +++ b/backend/internal/store/memory_test.go @@ -404,24 +404,55 @@ func TestMemoryFrameTemplateConstraints(t *testing.T) { func TestMemoryFrameTemplateReturnsCopies(t *testing.T) { // A handler applying an update mutates the struct it was handed, so the // store must not be sharing its own row. + // + // The byte slices matter more than the scalars here. `out := *in` already + // isolates a string field, so asserting only on Title would pass even if the + // slice copies were deleted, and the aliasing bug this test exists to catch + // would walk straight back in. Both slices are therefore written through by + // index: indexing can only reach shared backing memory, whereas an append + // might quietly reallocate and prove nothing. ctx := context.Background() m := store.NewMemory() - if err := m.CreateFrameTemplate(ctx, &store.FrameTemplate{ + const wantPrefill = "slots: {}\n" + const wantRules = `{"style":{"level":"required"}}` + input := &store.FrameTemplate{ ID: "t1", OrgID: "org-a", Title: "Brand Voice", - Prefill: []byte("slots: {}\n"), FieldRules: []byte("{}"), - }); err != nil { + Prefill: []byte(wantPrefill), FieldRules: []byte(wantRules), + } + if err := m.CreateFrameTemplate(ctx, input); err != nil { t.Fatalf("create: %v", err) } + + // The write path too: a caller that keeps hold of what it passed to Create + // must not be able to reach into the stored row through it. + input.Title = "clobbered on the way in" + input.Prefill[0] = 'X' + input.FieldRules[0] = 'X' + got, err := m.GetFrameTemplate(ctx, "org-a", "t1") if err != nil { t.Fatalf("get: %v", err) } - got.Title = "clobbered" + if got.Title != "Brand Voice" || string(got.Prefill) != wantPrefill || string(got.FieldRules) != wantRules { + t.Fatalf("Create aliased its input: %+v", got) + } + + // And the read path. + got.Title = "clobbered on the way out" + got.Prefill[0] = 'Y' + got.FieldRules[0] = 'Y' + again, err := m.GetFrameTemplate(ctx, "org-a", "t1") if err != nil { t.Fatalf("get again: %v", err) } if again.Title != "Brand Voice" { - t.Errorf("mutating the returned row changed the store: %q", again.Title) + t.Errorf("mutating the returned title changed the store: %q", again.Title) + } + if string(again.Prefill) != wantPrefill { + t.Errorf("mutating the returned prefill changed the store: %q", again.Prefill) + } + if string(again.FieldRules) != wantRules { + t.Errorf("mutating the returned field rules changed the store: %q", again.FieldRules) } } From 22aa74519b0140ee82a689a6db74563a1b6790a8 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:30:24 +0200 Subject: [PATCH 06/43] feat(store): persist frame templates in SQLite --- .../sqlite/migrations/006_frame_templates.sql | 25 +++ backend/internal/store/sqlite/sqlite.go | 116 ++++++++++++++ backend/internal/store/sqlite/sqlite_test.go | 150 ++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 backend/internal/store/sqlite/migrations/006_frame_templates.sql diff --git a/backend/internal/store/sqlite/migrations/006_frame_templates.sql b/backend/internal/store/sqlite/migrations/006_frame_templates.sql new file mode 100644 index 0000000..3d248eb --- /dev/null +++ b/backend/internal/store/sqlite/migrations/006_frame_templates.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- Frame templates are an authoring affordance, not Frames: they are copied once +-- at creation and nothing is recorded on the Frame that came from one, so there +-- is no foreign key back to frames and no version history here. See +-- docs/adr/0001-frame-templates-are-not-frames.md. +-- +-- Built-in templates are NOT rows. They are compiled into the binary, because +-- seeding them per org would mean reconciling N rows on every upgrade. +CREATE TABLE frame_templates ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES orgs(id), + title TEXT NOT NULL, + description TEXT NOT NULL, + -- Opaque blobs so that adding a slot to frames.SlotTable never touches this + -- schema, the same reasoning as frame_versions.content. + prefill BLOB NOT NULL, -- canonical YAML: slots + extends + field_rules BLOB NOT NULL, -- JSON: slot key -> {level, note} + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (org_id, title) +); + +-- +goose Down +DROP TABLE frame_templates; diff --git a/backend/internal/store/sqlite/sqlite.go b/backend/internal/store/sqlite/sqlite.go index 0aac913..be00a50 100644 --- a/backend/internal/store/sqlite/sqlite.go +++ b/backend/internal/store/sqlite/sqlite.go @@ -541,6 +541,122 @@ func (r *Repository) FrameChildren(ctx context.Context, parentFrameID string) ([ return out, rows.Err() } +// Frame templates. Prefill and field_rules are stored as written: this layer +// does not know or care what is inside them, which is what keeps a slot change +// out of this schema. + +const frameTemplateCols = `id, org_id, title, description, prefill, field_rules, created_by, created_at, updated_at` + +func (r *Repository) CreateFrameTemplate(ctx context.Context, t *store.FrameTemplate) error { + _, err := r.db.ExecContext(ctx, + `INSERT INTO frame_templates (`+frameTemplateCols+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + t.ID, t.OrgID, t.Title, t.Description, t.Prefill, t.FieldRules, t.CreatedBy, + t.CreatedAt.UTC().Format(time.RFC3339), t.UpdatedAt.UTC().Format(time.RFC3339)) + if err != nil { + // Both the primary key and UNIQUE (org_id, title) land here. The handler + // needs a domain error to map, not a driver string. + if isUnique(err) { + return store.ErrAlreadyExists + } + return err + } + return nil +} + +func (r *Repository) UpdateFrameTemplate(ctx context.Context, t *store.FrameTemplate) error { + // org_id is in the WHERE clause rather than checked beforehand, so a row in + // another org is simply not matched: no separate read, no race between the + // check and the write. + res, err := r.db.ExecContext(ctx, + `UPDATE frame_templates + SET title = ?, description = ?, prefill = ?, field_rules = ?, updated_at = ? + WHERE id = ? AND org_id = ?`, + t.Title, t.Description, t.Prefill, t.FieldRules, + t.UpdatedAt.UTC().Format(time.RFC3339), t.ID, t.OrgID) + if err != nil { + if isUnique(err) { + return store.ErrAlreadyExists + } + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return store.ErrNotFound + } + return nil +} + +func (r *Repository) DeleteFrameTemplate(ctx context.Context, orgID, id string) error { + res, err := r.db.ExecContext(ctx, + `DELETE FROM frame_templates WHERE id = ? AND org_id = ?`, id, orgID) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return store.ErrNotFound + } + return nil +} + +func (r *Repository) GetFrameTemplate(ctx context.Context, orgID, id string) (*store.FrameTemplate, error) { + row := r.db.QueryRowContext(ctx, + `SELECT `+frameTemplateCols+` FROM frame_templates WHERE id = ? AND org_id = ?`, id, orgID) + t, err := scanFrameTemplate(row) + if errors.Is(err, sql.ErrNoRows) { + // A row in another org is reported as absent, so existence does not leak. + return nil, store.ErrNotFound + } + if err != nil { + return nil, err + } + return t, nil +} + +func (r *Repository) ListFrameTemplatesByOrg(ctx context.Context, orgID string) ([]*store.FrameTemplate, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT `+frameTemplateCols+` FROM frame_templates WHERE org_id = ? ORDER BY title`, orgID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + out := []*store.FrameTemplate{} + for rows.Next() { + t, err := scanFrameTemplate(rows) + if err != nil { + return nil, err + } + out = append(out, t) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +// rowScanner is satisfied by both *sql.Row and *sql.Rows, so the column list is +// written once. There is no existing scanner interface in this file: scanOrg and +// scanMemberships each take a concrete type instead, one per shape they scan. +type rowScanner interface{ Scan(dest ...any) error } + +func scanFrameTemplate(s rowScanner) (*store.FrameTemplate, error) { + var t store.FrameTemplate + var created, updated string + if err := s.Scan(&t.ID, &t.OrgID, &t.Title, &t.Description, &t.Prefill, &t.FieldRules, + &t.CreatedBy, &created, &updated); err != nil { + return nil, err + } + t.CreatedAt = ts(created).AsTime() + t.UpdatedAt = ts(updated).AsTime() + return &t, nil +} + func (r *Repository) DeleteFrame(ctx context.Context, frameID string) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { diff --git a/backend/internal/store/sqlite/sqlite_test.go b/backend/internal/store/sqlite/sqlite_test.go index 915de8c..e07df90 100644 --- a/backend/internal/store/sqlite/sqlite_test.go +++ b/backend/internal/store/sqlite/sqlite_test.go @@ -790,3 +790,153 @@ func TestMembershipEmailsAreCanonical(t *testing.T) { } }) } + +func TestSQLiteFrameTemplateCRUD(t *testing.T) { + ctx := context.Background() + r := newRepo(t) // existing helper in this file: opens a migrated DB under t.TempDir() + // A template references an org, so one has to exist. + if err := r.CreateOrg(ctx, &framesv1.Org{ + Id: "org-a", Slug: "acme", DisplayName: "Acme", CreatedAt: timestamppb.Now(), + }); err != nil { + t.Fatalf("create org: %v", err) + } + if err := r.CreateOrg(ctx, &framesv1.Org{ + Id: "org-b", Slug: "other", DisplayName: "Other", CreatedAt: timestamppb.Now(), + }); err != nil { + t.Fatalf("create other org: %v", err) + } + + created := time.Unix(1700000000, 0).UTC() + row := &store.FrameTemplate{ + ID: "t1", OrgID: "org-a", Title: "Brand Voice", Description: "How we sound", + Prefill: []byte("slots:\n style: Plain sentences.\n"), + FieldRules: []byte(`{"style":{"level":"required","note":"Voice and tone."}}`), + CreatedBy: "user-1", CreatedAt: created, UpdatedAt: created, + } + if err := r.CreateFrameTemplate(ctx, row); err != nil { + t.Fatalf("create: %v", err) + } + + got, err := r.GetFrameTemplate(ctx, "org-a", "t1") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Title != "Brand Voice" || got.CreatedBy != "user-1" { + t.Errorf("round trip lost scalars: %+v", got) + } + if string(got.Prefill) != string(row.Prefill) { + t.Errorf("prefill = %q, want %q", got.Prefill, row.Prefill) + } + if string(got.FieldRules) != string(row.FieldRules) { + t.Errorf("field_rules = %q, want %q", got.FieldRules, row.FieldRules) + } + if !got.CreatedAt.Equal(created) { + t.Errorf("created_at = %v, want %v", got.CreatedAt, created) + } + + if _, err := r.GetFrameTemplate(ctx, "org-b", "t1"); !errors.Is(err, store.ErrNotFound) { + t.Errorf("cross-org get = %v, want ErrNotFound", err) + } + + row.Title = "Brand Voice v2" + row.UpdatedAt = created.Add(time.Minute) + if err := r.UpdateFrameTemplate(ctx, row); err != nil { + t.Fatalf("update: %v", err) + } + got, err = r.GetFrameTemplate(ctx, "org-a", "t1") + if err != nil { + t.Fatalf("get after update: %v", err) + } + if got.Title != "Brand Voice v2" || !got.UpdatedAt.Equal(created.Add(time.Minute)) { + t.Errorf("update not applied: %+v", got) + } + + if err := r.DeleteFrameTemplate(ctx, "org-a", "t1"); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := r.GetFrameTemplate(ctx, "org-a", "t1"); !errors.Is(err, store.ErrNotFound) { + t.Errorf("get after delete = %v, want ErrNotFound", err) + } +} + +func TestSQLiteFrameTemplateConstraints(t *testing.T) { + ctx := context.Background() + r := newRepo(t) + for _, id := range []string{"org-a", "org-b"} { + if err := r.CreateOrg(ctx, &framesv1.Org{ + Id: id, Slug: id, DisplayName: id, CreatedAt: timestamppb.Now(), + }); err != nil { + t.Fatalf("create org %s: %v", id, err) + } + } + mk := func(id, orgID, title string) *store.FrameTemplate { + now := time.Unix(1700000000, 0).UTC() + return &store.FrameTemplate{ + ID: id, OrgID: orgID, Title: title, Description: "d", + Prefill: []byte("slots: {}\n"), FieldRules: []byte("{}"), + CreatedBy: "u", CreatedAt: now, UpdatedAt: now, + } + } + if err := r.CreateFrameTemplate(ctx, mk("t1", "org-a", "Brand Voice")); err != nil { + t.Fatalf("seed: %v", err) + } + + // The UNIQUE (org_id, title) constraint has to surface as ErrAlreadyExists, + // not a raw driver error, or the handler cannot map it to a useful code. + if err := r.CreateFrameTemplate(ctx, mk("t2", "org-a", "Brand Voice")); !errors.Is(err, store.ErrAlreadyExists) { + t.Errorf("duplicate title in org = %v, want ErrAlreadyExists", err) + } + // The control: the same title in another org must succeed, or the test above + // would pass for the wrong reason. + if err := r.CreateFrameTemplate(ctx, mk("t3", "org-b", "Brand Voice")); err != nil { + t.Errorf("same title in another org = %v, want success", err) + } + if err := r.CreateFrameTemplate(ctx, mk("t1", "org-a", "Different")); !errors.Is(err, store.ErrAlreadyExists) { + t.Errorf("duplicate id = %v, want ErrAlreadyExists", err) + } + if err := r.UpdateFrameTemplate(ctx, mk("nope", "org-a", "X")); !errors.Is(err, store.ErrNotFound) { + t.Errorf("update missing = %v, want ErrNotFound", err) + } + if err := r.UpdateFrameTemplate(ctx, mk("t1", "org-b", "Brand Voice")); !errors.Is(err, store.ErrNotFound) { + t.Errorf("update across orgs = %v, want ErrNotFound", err) + } + if err := r.DeleteFrameTemplate(ctx, "org-b", "t1"); !errors.Is(err, store.ErrNotFound) { + t.Errorf("delete across orgs = %v, want ErrNotFound", err) + } +} + +func TestSQLiteFrameTemplateListIsOrgScopedAndOrdered(t *testing.T) { + ctx := context.Background() + r := newRepo(t) + for _, id := range []string{"org-a", "org-b"} { + if err := r.CreateOrg(ctx, &framesv1.Org{ + Id: id, Slug: id, DisplayName: id, CreatedAt: timestamppb.Now(), + }); err != nil { + t.Fatalf("create org %s: %v", id, err) + } + } + now := time.Unix(1700000000, 0).UTC() + for _, spec := range []struct{ id, org, title string }{ + {"t1", "org-a", "Zebra"}, + {"t2", "org-a", "Alpha"}, + {"t3", "org-b", "Other org"}, + } { + if err := r.CreateFrameTemplate(ctx, &store.FrameTemplate{ + ID: spec.id, OrgID: spec.org, Title: spec.title, Description: "d", + Prefill: []byte("slots: {}\n"), FieldRules: []byte("{}"), + CreatedBy: "u", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed %s: %v", spec.id, err) + } + } + rows, err := r.ListFrameTemplatesByOrg(ctx, "org-a") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rows) != 2 { + t.Fatalf("got %d rows, want 2 (org-b's must not appear)", len(rows)) + } + if rows[0].Title != "Alpha" || rows[1].Title != "Zebra" { + t.Errorf("order = %q, %q, want Alpha then Zebra", rows[0].Title, rows[1].Title) + } +} From 052a08ec2b7ee25dc2af798f9bdf18bb7bb7b974 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:35:58 +0200 Subject: [PATCH 07/43] feat(proto): add the Frame template RPCs and template_id on publish --- gen/go/frames/v1/frame.pb.go | 359 ++++++++- gen/go/frames/v1/frame_service.pb.go | 747 ++++++++++++++++-- .../framesv1connect/frame_service.connect.go | 187 ++++- gen/ts/frames/v1/frame_pb.ts | 157 +++- gen/ts/frames/v1/frame_service_pb.ts | 273 ++++++- proto/frames/v1/frame.proto | 45 ++ proto/frames/v1/frame_service.proto | 50 ++ 7 files changed, 1690 insertions(+), 128 deletions(-) diff --git a/gen/go/frames/v1/frame.pb.go b/gen/go/frames/v1/frame.pb.go index 0350643..5f72070 100644 --- a/gen/go/frames/v1/frame.pb.go +++ b/gen/go/frames/v1/frame.pb.go @@ -22,6 +22,61 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// Requirement is how strongly a template wants a slot filled. Stricter than the +// Frame schema, which requires no slot at all: an org uses it to hold its own +// Frames to a house standard the spec does not impose. +type Requirement int32 + +const ( + Requirement_REQUIREMENT_UNSPECIFIED Requirement = 0 // treated as optional + Requirement_REQUIREMENT_OPTIONAL Requirement = 1 + Requirement_REQUIREMENT_RECOMMENDED Requirement = 2 + Requirement_REQUIREMENT_REQUIRED Requirement = 3 +) + +// Enum value maps for Requirement. +var ( + Requirement_name = map[int32]string{ + 0: "REQUIREMENT_UNSPECIFIED", + 1: "REQUIREMENT_OPTIONAL", + 2: "REQUIREMENT_RECOMMENDED", + 3: "REQUIREMENT_REQUIRED", + } + Requirement_value = map[string]int32{ + "REQUIREMENT_UNSPECIFIED": 0, + "REQUIREMENT_OPTIONAL": 1, + "REQUIREMENT_RECOMMENDED": 2, + "REQUIREMENT_REQUIRED": 3, + } +) + +func (x Requirement) Enum() *Requirement { + p := new(Requirement) + *p = x + return p +} + +func (x Requirement) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Requirement) Descriptor() protoreflect.EnumDescriptor { + return file_frames_v1_frame_proto_enumTypes[0].Descriptor() +} + +func (Requirement) Type() protoreflect.EnumType { + return &file_frames_v1_frame_proto_enumTypes[0] +} + +func (x Requirement) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Requirement.Descriptor instead. +func (Requirement) EnumDescriptor() ([]byte, []int) { + return file_frames_v1_frame_proto_rawDescGZIP(), []int{0} +} + type Org struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -622,6 +677,224 @@ func (x *FrameVersionSummary) GetPublishedAt() *timestamppb.Timestamp { return nil } +// FieldRule is a template's expectation for one slot. `note` is the template +// author's own guidance on what belongs there: the seeded authoring form shows +// it in place of the generic per-slot hint, and an MCP client asks it as a +// question. A message rather than a bare Requirement because widening a map's +// value type later would be a breaking wire change. +type FieldRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + Level Requirement `protobuf:"varint,1,opt,name=level,proto3,enum=frames.v1.Requirement" json:"level,omitempty"` + Note string `protobuf:"bytes,2,opt,name=note,proto3" json:"note,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FieldRule) Reset() { + *x = FieldRule{} + mi := &file_frames_v1_frame_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FieldRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FieldRule) ProtoMessage() {} + +func (x *FieldRule) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FieldRule.ProtoReflect.Descriptor instead. +func (*FieldRule) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_proto_rawDescGZIP(), []int{8} +} + +func (x *FieldRule) GetLevel() Requirement { + if x != nil { + return x.Level + } + return Requirement_REQUIREMENT_UNSPECIFIED +} + +func (x *FieldRule) GetNote() string { + if x != nil { + return x.Note + } + return "" +} + +// FrameTemplateSummary is what a picker needs: no prefill, so listing stays +// cheap. `builtin` is computed server-side so no client has to parse an id. +type FrameTemplateSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + Builtin bool `protobuf:"varint,4,opt,name=builtin,proto3" json:"builtin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FrameTemplateSummary) Reset() { + *x = FrameTemplateSummary{} + mi := &file_frames_v1_frame_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FrameTemplateSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FrameTemplateSummary) ProtoMessage() {} + +func (x *FrameTemplateSummary) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FrameTemplateSummary.ProtoReflect.Descriptor instead. +func (*FrameTemplateSummary) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_proto_rawDescGZIP(), []int{9} +} + +func (x *FrameTemplateSummary) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *FrameTemplateSummary) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *FrameTemplateSummary) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *FrameTemplateSummary) GetBuiltin() bool { + if x != nil { + return x.Builtin + } + return false +} + +type FrameTemplate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + Builtin bool `protobuf:"varint,4,opt,name=builtin,proto3" json:"builtin,omitempty"` + // Canonical YAML carrying only `slots` and `extends`. Bytes rather than a + // structured message on purpose: a message mirroring the slot schema would be + // a second place to update whenever frames.SlotTable changes, which is what + // the reflective guards in the MCP tests exist to catch. + Prefill []byte `protobuf:"bytes,5,opt,name=prefill,proto3" json:"prefill,omitempty"` + // Slot key (terminology, rules, skills, prompts, tool_specs, goals, style, + // norms, architecture, business_process) -> rule. Keyed by string, so adding + // a slot does not change this shape either. + FieldRules map[string]*FieldRule `protobuf:"bytes,6,rep,name=field_rules,json=fieldRules,proto3" json:"field_rules,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FrameTemplate) Reset() { + *x = FrameTemplate{} + mi := &file_frames_v1_frame_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FrameTemplate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FrameTemplate) ProtoMessage() {} + +func (x *FrameTemplate) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FrameTemplate.ProtoReflect.Descriptor instead. +func (*FrameTemplate) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_proto_rawDescGZIP(), []int{10} +} + +func (x *FrameTemplate) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *FrameTemplate) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *FrameTemplate) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *FrameTemplate) GetBuiltin() bool { + if x != nil { + return x.Builtin + } + return false +} + +func (x *FrameTemplate) GetPrefill() []byte { + if x != nil { + return x.Prefill + } + return nil +} + +func (x *FrameTemplate) GetFieldRules() map[string]*FieldRule { + if x != nil { + return x.FieldRules + } + return nil +} + var File_frames_v1_frame_proto protoreflect.FileDescriptor const file_frames_v1_frame_proto_rawDesc = "" + @@ -680,7 +953,31 @@ const file_frames_v1_frame_proto_rawDesc = "" + "\aversion\x18\x01 \x01(\tR\aversion\x12\x1c\n" + "\tchangelog\x18\x02 \x01(\tR\tchangelog\x12!\n" + "\fpublished_by\x18\x03 \x01(\tR\vpublishedBy\x12=\n" + - "\fpublished_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\vpublishedAtB\x9f\x01\n" + + "\fpublished_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\vpublishedAt\"M\n" + + "\tFieldRule\x12,\n" + + "\x05level\x18\x01 \x01(\x0e2\x16.frames.v1.RequirementR\x05level\x12\x12\n" + + "\x04note\x18\x02 \x01(\tR\x04note\"x\n" + + "\x14FrameTemplateSummary\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + + "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x18\n" + + "\abuiltin\x18\x04 \x01(\bR\abuiltin\"\xab\x02\n" + + "\rFrameTemplate\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + + "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x18\n" + + "\abuiltin\x18\x04 \x01(\bR\abuiltin\x12\x18\n" + + "\aprefill\x18\x05 \x01(\fR\aprefill\x12I\n" + + "\vfield_rules\x18\x06 \x03(\v2(.frames.v1.FrameTemplate.FieldRulesEntryR\n" + + "fieldRules\x1aS\n" + + "\x0fFieldRulesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" + + "\x05value\x18\x02 \x01(\v2\x14.frames.v1.FieldRuleR\x05value:\x028\x01*{\n" + + "\vRequirement\x12\x1b\n" + + "\x17REQUIREMENT_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14REQUIREMENT_OPTIONAL\x10\x01\x12\x1b\n" + + "\x17REQUIREMENT_RECOMMENDED\x10\x02\x12\x18\n" + + "\x14REQUIREMENT_REQUIRED\x10\x03B\x9f\x01\n" + "\rcom.frames.v1B\n" + "FrameProtoP\x01Z=github.com/nebari-dev/nebari-frames/gen/go/frames/v1;framesv1\xa2\x02\x03FXX\xaa\x02\tFrames.V1\xca\x02\tFrames\\V1\xe2\x02\x15Frames\\V1\\GPBMetadata\xea\x02\n" + "Frames::V1b\x06proto3" @@ -697,32 +994,41 @@ func file_frames_v1_frame_proto_rawDescGZIP() []byte { return file_frames_v1_frame_proto_rawDescData } -var file_frames_v1_frame_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_frames_v1_frame_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_frames_v1_frame_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_frames_v1_frame_proto_goTypes = []any{ - (*Org)(nil), // 0: frames.v1.Org - (*Membership)(nil), // 1: frames.v1.Membership - (*Frame)(nil), // 2: frames.v1.Frame - (*ParentRef)(nil), // 3: frames.v1.ParentRef - (*FrameVersion)(nil), // 4: frames.v1.FrameVersion - (*Permissions)(nil), // 5: frames.v1.Permissions - (*FrameSummary)(nil), // 6: frames.v1.FrameSummary - (*FrameVersionSummary)(nil), // 7: frames.v1.FrameVersionSummary - (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp + (Requirement)(0), // 0: frames.v1.Requirement + (*Org)(nil), // 1: frames.v1.Org + (*Membership)(nil), // 2: frames.v1.Membership + (*Frame)(nil), // 3: frames.v1.Frame + (*ParentRef)(nil), // 4: frames.v1.ParentRef + (*FrameVersion)(nil), // 5: frames.v1.FrameVersion + (*Permissions)(nil), // 6: frames.v1.Permissions + (*FrameSummary)(nil), // 7: frames.v1.FrameSummary + (*FrameVersionSummary)(nil), // 8: frames.v1.FrameVersionSummary + (*FieldRule)(nil), // 9: frames.v1.FieldRule + (*FrameTemplateSummary)(nil), // 10: frames.v1.FrameTemplateSummary + (*FrameTemplate)(nil), // 11: frames.v1.FrameTemplate + nil, // 12: frames.v1.FrameTemplate.FieldRulesEntry + (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp } var file_frames_v1_frame_proto_depIdxs = []int32{ - 8, // 0: frames.v1.Org.created_at:type_name -> google.protobuf.Timestamp - 8, // 1: frames.v1.Membership.added_at:type_name -> google.protobuf.Timestamp - 8, // 2: frames.v1.Frame.created_at:type_name -> google.protobuf.Timestamp - 8, // 3: frames.v1.Frame.updated_at:type_name -> google.protobuf.Timestamp - 8, // 4: frames.v1.FrameVersion.published_at:type_name -> google.protobuf.Timestamp - 8, // 5: frames.v1.FrameSummary.updated_at:type_name -> google.protobuf.Timestamp - 5, // 6: frames.v1.FrameSummary.permissions:type_name -> frames.v1.Permissions - 8, // 7: frames.v1.FrameVersionSummary.published_at:type_name -> google.protobuf.Timestamp - 8, // [8:8] is the sub-list for method output_type - 8, // [8:8] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 13, // 0: frames.v1.Org.created_at:type_name -> google.protobuf.Timestamp + 13, // 1: frames.v1.Membership.added_at:type_name -> google.protobuf.Timestamp + 13, // 2: frames.v1.Frame.created_at:type_name -> google.protobuf.Timestamp + 13, // 3: frames.v1.Frame.updated_at:type_name -> google.protobuf.Timestamp + 13, // 4: frames.v1.FrameVersion.published_at:type_name -> google.protobuf.Timestamp + 13, // 5: frames.v1.FrameSummary.updated_at:type_name -> google.protobuf.Timestamp + 6, // 6: frames.v1.FrameSummary.permissions:type_name -> frames.v1.Permissions + 13, // 7: frames.v1.FrameVersionSummary.published_at:type_name -> google.protobuf.Timestamp + 0, // 8: frames.v1.FieldRule.level:type_name -> frames.v1.Requirement + 12, // 9: frames.v1.FrameTemplate.field_rules:type_name -> frames.v1.FrameTemplate.FieldRulesEntry + 9, // 10: frames.v1.FrameTemplate.FieldRulesEntry.value:type_name -> frames.v1.FieldRule + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name } func init() { file_frames_v1_frame_proto_init() } @@ -735,13 +1041,14 @@ func file_frames_v1_frame_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_frames_v1_frame_proto_rawDesc), len(file_frames_v1_frame_proto_rawDesc)), - NumEnums: 0, - NumMessages: 8, + NumEnums: 1, + NumMessages: 12, NumExtensions: 0, NumServices: 0, }, GoTypes: file_frames_v1_frame_proto_goTypes, DependencyIndexes: file_frames_v1_frame_proto_depIdxs, + EnumInfos: file_frames_v1_frame_proto_enumTypes, MessageInfos: file_frames_v1_frame_proto_msgTypes, }.Build() File_frames_v1_frame_proto = out.File diff --git a/gen/go/frames/v1/frame_service.pb.go b/gen/go/frames/v1/frame_service.pb.go index 4db8e32..8db4707 100644 --- a/gen/go/frames/v1/frame_service.pb.go +++ b/gen/go/frames/v1/frame_service.pb.go @@ -22,9 +22,14 @@ const ( ) type PublishFrameRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Content []byte `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` // full YAML; name/version/extends/excludes/slots parsed server-side - Changelog string `protobuf:"bytes,2,opt,name=changelog,proto3" json:"changelog,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Content []byte `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` // full YAML; name/version/extends/excludes/slots parsed server-side + Changelog string `protobuf:"bytes,2,opt,name=changelog,proto3" json:"changelog,omitempty"` + // Optional. Names the template this Frame is being created from, so its + // required slots are checked. Used for validation and then discarded: + // nothing is recorded on the Frame, and the check applies on create only. + // Empty means no template and no checks. + TemplateId string `protobuf:"bytes,3,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -73,6 +78,13 @@ func (x *PublishFrameRequest) GetChangelog() string { return "" } +func (x *PublishFrameRequest) GetTemplateId() string { + if x != nil { + return x.TemplateId + } + return "" +} + type PublishFrameResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Frame *Frame `protobuf:"bytes,1,opt,name=frame,proto3" json:"frame,omitempty"` @@ -1412,14 +1424,506 @@ func (*RemoveOrgMemberResponse) Descriptor() ([]byte, []int) { return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{26} } +type ListFrameTemplatesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListFrameTemplatesRequest) Reset() { + *x = ListFrameTemplatesRequest{} + mi := &file_frames_v1_frame_service_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListFrameTemplatesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListFrameTemplatesRequest) ProtoMessage() {} + +func (x *ListFrameTemplatesRequest) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_service_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListFrameTemplatesRequest.ProtoReflect.Descriptor instead. +func (*ListFrameTemplatesRequest) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{27} +} + +type ListFrameTemplatesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Templates []*FrameTemplateSummary `protobuf:"bytes,1,rep,name=templates,proto3" json:"templates,omitempty"` + // Whether the caller may create, edit, and delete org templates, so the SPA + // does not have to infer admin-ness itself. Mirrors ListFramesResponse.can_create. + CanManage bool `protobuf:"varint,2,opt,name=can_manage,json=canManage,proto3" json:"can_manage,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListFrameTemplatesResponse) Reset() { + *x = ListFrameTemplatesResponse{} + mi := &file_frames_v1_frame_service_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListFrameTemplatesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListFrameTemplatesResponse) ProtoMessage() {} + +func (x *ListFrameTemplatesResponse) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_service_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListFrameTemplatesResponse.ProtoReflect.Descriptor instead. +func (*ListFrameTemplatesResponse) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{28} +} + +func (x *ListFrameTemplatesResponse) GetTemplates() []*FrameTemplateSummary { + if x != nil { + return x.Templates + } + return nil +} + +func (x *ListFrameTemplatesResponse) GetCanManage() bool { + if x != nil { + return x.CanManage + } + return false +} + +type GetFrameTemplateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetFrameTemplateRequest) Reset() { + *x = GetFrameTemplateRequest{} + mi := &file_frames_v1_frame_service_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetFrameTemplateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetFrameTemplateRequest) ProtoMessage() {} + +func (x *GetFrameTemplateRequest) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_service_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetFrameTemplateRequest.ProtoReflect.Descriptor instead. +func (*GetFrameTemplateRequest) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{29} +} + +func (x *GetFrameTemplateRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetFrameTemplateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Template *FrameTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetFrameTemplateResponse) Reset() { + *x = GetFrameTemplateResponse{} + mi := &file_frames_v1_frame_service_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetFrameTemplateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetFrameTemplateResponse) ProtoMessage() {} + +func (x *GetFrameTemplateResponse) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_service_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetFrameTemplateResponse.ProtoReflect.Descriptor instead. +func (*GetFrameTemplateResponse) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{30} +} + +func (x *GetFrameTemplateResponse) GetTemplate() *FrameTemplate { + if x != nil { + return x.Template + } + return nil +} + +type CreateFrameTemplateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + Prefill []byte `protobuf:"bytes,3,opt,name=prefill,proto3" json:"prefill,omitempty"` + FieldRules map[string]*FieldRule `protobuf:"bytes,4,rep,name=field_rules,json=fieldRules,proto3" json:"field_rules,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateFrameTemplateRequest) Reset() { + *x = CreateFrameTemplateRequest{} + mi := &file_frames_v1_frame_service_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateFrameTemplateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateFrameTemplateRequest) ProtoMessage() {} + +func (x *CreateFrameTemplateRequest) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_service_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateFrameTemplateRequest.ProtoReflect.Descriptor instead. +func (*CreateFrameTemplateRequest) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{31} +} + +func (x *CreateFrameTemplateRequest) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *CreateFrameTemplateRequest) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *CreateFrameTemplateRequest) GetPrefill() []byte { + if x != nil { + return x.Prefill + } + return nil +} + +func (x *CreateFrameTemplateRequest) GetFieldRules() map[string]*FieldRule { + if x != nil { + return x.FieldRules + } + return nil +} + +type CreateFrameTemplateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Template *FrameTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateFrameTemplateResponse) Reset() { + *x = CreateFrameTemplateResponse{} + mi := &file_frames_v1_frame_service_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateFrameTemplateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateFrameTemplateResponse) ProtoMessage() {} + +func (x *CreateFrameTemplateResponse) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_service_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateFrameTemplateResponse.ProtoReflect.Descriptor instead. +func (*CreateFrameTemplateResponse) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{32} +} + +func (x *CreateFrameTemplateResponse) GetTemplate() *FrameTemplate { + if x != nil { + return x.Template + } + return nil +} + +type UpdateFrameTemplateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + Prefill []byte `protobuf:"bytes,4,opt,name=prefill,proto3" json:"prefill,omitempty"` + FieldRules map[string]*FieldRule `protobuf:"bytes,5,rep,name=field_rules,json=fieldRules,proto3" json:"field_rules,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateFrameTemplateRequest) Reset() { + *x = UpdateFrameTemplateRequest{} + mi := &file_frames_v1_frame_service_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateFrameTemplateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateFrameTemplateRequest) ProtoMessage() {} + +func (x *UpdateFrameTemplateRequest) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_service_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateFrameTemplateRequest.ProtoReflect.Descriptor instead. +func (*UpdateFrameTemplateRequest) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{33} +} + +func (x *UpdateFrameTemplateRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *UpdateFrameTemplateRequest) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *UpdateFrameTemplateRequest) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *UpdateFrameTemplateRequest) GetPrefill() []byte { + if x != nil { + return x.Prefill + } + return nil +} + +func (x *UpdateFrameTemplateRequest) GetFieldRules() map[string]*FieldRule { + if x != nil { + return x.FieldRules + } + return nil +} + +type UpdateFrameTemplateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Template *FrameTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateFrameTemplateResponse) Reset() { + *x = UpdateFrameTemplateResponse{} + mi := &file_frames_v1_frame_service_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateFrameTemplateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateFrameTemplateResponse) ProtoMessage() {} + +func (x *UpdateFrameTemplateResponse) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_service_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateFrameTemplateResponse.ProtoReflect.Descriptor instead. +func (*UpdateFrameTemplateResponse) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{34} +} + +func (x *UpdateFrameTemplateResponse) GetTemplate() *FrameTemplate { + if x != nil { + return x.Template + } + return nil +} + +type DeleteFrameTemplateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteFrameTemplateRequest) Reset() { + *x = DeleteFrameTemplateRequest{} + mi := &file_frames_v1_frame_service_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteFrameTemplateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteFrameTemplateRequest) ProtoMessage() {} + +func (x *DeleteFrameTemplateRequest) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_service_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteFrameTemplateRequest.ProtoReflect.Descriptor instead. +func (*DeleteFrameTemplateRequest) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{35} +} + +func (x *DeleteFrameTemplateRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type DeleteFrameTemplateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteFrameTemplateResponse) Reset() { + *x = DeleteFrameTemplateResponse{} + mi := &file_frames_v1_frame_service_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteFrameTemplateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteFrameTemplateResponse) ProtoMessage() {} + +func (x *DeleteFrameTemplateResponse) ProtoReflect() protoreflect.Message { + mi := &file_frames_v1_frame_service_proto_msgTypes[36] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteFrameTemplateResponse.ProtoReflect.Descriptor instead. +func (*DeleteFrameTemplateResponse) Descriptor() ([]byte, []int) { + return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{36} +} + var File_frames_v1_frame_service_proto protoreflect.FileDescriptor const file_frames_v1_frame_service_proto_rawDesc = "" + "\n" + - "\x1dframes/v1/frame_service.proto\x12\tframes.v1\x1a\x15frames/v1/frame.proto\"M\n" + + "\x1dframes/v1/frame_service.proto\x12\tframes.v1\x1a\x15frames/v1/frame.proto\"n\n" + "\x13PublishFrameRequest\x12\x18\n" + "\acontent\x18\x01 \x01(\fR\acontent\x12\x1c\n" + - "\tchangelog\x18\x02 \x01(\tR\tchangelog\"q\n" + + "\tchangelog\x18\x02 \x01(\tR\tchangelog\x12\x1f\n" + + "\vtemplate_id\x18\x03 \x01(\tR\n" + + "templateId\"q\n" + "\x14PublishFrameResponse\x12&\n" + "\x05frame\x18\x01 \x01(\v2\x10.frames.v1.FrameR\x05frame\x121\n" + "\aversion\x18\x02 \x01(\v2\x17.frames.v1.FrameVersionR\aversion\"\x13\n" + @@ -1495,7 +1999,42 @@ const file_frames_v1_frame_service_proto_rawDesc = "" + "\x16RemoveOrgMemberRequest\x12\x19\n" + "\buser_sub\x18\x01 \x01(\tR\auserSub\x12\x14\n" + "\x05email\x18\x02 \x01(\tR\x05email\"\x19\n" + - "\x17RemoveOrgMemberResponse2\xd1\a\n" + + "\x17RemoveOrgMemberResponse\"\x1b\n" + + "\x19ListFrameTemplatesRequest\"z\n" + + "\x1aListFrameTemplatesResponse\x12=\n" + + "\ttemplates\x18\x01 \x03(\v2\x1f.frames.v1.FrameTemplateSummaryR\ttemplates\x12\x1d\n" + + "\n" + + "can_manage\x18\x02 \x01(\bR\tcanManage\")\n" + + "\x17GetFrameTemplateRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"P\n" + + "\x18GetFrameTemplateResponse\x124\n" + + "\btemplate\x18\x01 \x01(\v2\x18.frames.v1.FrameTemplateR\btemplate\"\x9b\x02\n" + + "\x1aCreateFrameTemplateRequest\x12\x14\n" + + "\x05title\x18\x01 \x01(\tR\x05title\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x18\n" + + "\aprefill\x18\x03 \x01(\fR\aprefill\x12V\n" + + "\vfield_rules\x18\x04 \x03(\v25.frames.v1.CreateFrameTemplateRequest.FieldRulesEntryR\n" + + "fieldRules\x1aS\n" + + "\x0fFieldRulesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" + + "\x05value\x18\x02 \x01(\v2\x14.frames.v1.FieldRuleR\x05value:\x028\x01\"S\n" + + "\x1bCreateFrameTemplateResponse\x124\n" + + "\btemplate\x18\x01 \x01(\v2\x18.frames.v1.FrameTemplateR\btemplate\"\xab\x02\n" + + "\x1aUpdateFrameTemplateRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + + "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x18\n" + + "\aprefill\x18\x04 \x01(\fR\aprefill\x12V\n" + + "\vfield_rules\x18\x05 \x03(\v25.frames.v1.UpdateFrameTemplateRequest.FieldRulesEntryR\n" + + "fieldRules\x1aS\n" + + "\x0fFieldRulesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" + + "\x05value\x18\x02 \x01(\v2\x14.frames.v1.FieldRuleR\x05value:\x028\x01\"S\n" + + "\x1bUpdateFrameTemplateResponse\x124\n" + + "\btemplate\x18\x01 \x01(\v2\x18.frames.v1.FrameTemplateR\btemplate\",\n" + + "\x1aDeleteFrameTemplateRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x1d\n" + + "\x1bDeleteFrameTemplateResponse2\xc3\v\n" + "\fFrameService\x12O\n" + "\fPublishFrame\x12\x1e.frames.v1.PublishFrameRequest\x1a\x1f.frames.v1.PublishFrameResponse\x12I\n" + "\n" + @@ -1505,7 +2044,12 @@ const file_frames_v1_frame_service_proto_rawDesc = "" + "\x05GetMe\x12\x17.frames.v1.GetMeRequest\x1a\x18.frames.v1.GetMeResponse\x12^\n" + "\x11ListFrameVersions\x12#.frames.v1.ListFrameVersionsRequest\x1a$.frames.v1.ListFrameVersionsResponse\x12L\n" + "\vDeleteFrame\x12\x1d.frames.v1.DeleteFrameRequest\x1a\x1e.frames.v1.DeleteFrameResponse\x12O\n" + - "\fConvertFrame\x12\x1e.frames.v1.ConvertFrameRequest\x1a\x1f.frames.v1.ConvertFrameResponse\x12U\n" + + "\fConvertFrame\x12\x1e.frames.v1.ConvertFrameRequest\x1a\x1f.frames.v1.ConvertFrameResponse\x12a\n" + + "\x12ListFrameTemplates\x12$.frames.v1.ListFrameTemplatesRequest\x1a%.frames.v1.ListFrameTemplatesResponse\x12[\n" + + "\x10GetFrameTemplate\x12\".frames.v1.GetFrameTemplateRequest\x1a#.frames.v1.GetFrameTemplateResponse\x12d\n" + + "\x13CreateFrameTemplate\x12%.frames.v1.CreateFrameTemplateRequest\x1a&.frames.v1.CreateFrameTemplateResponse\x12d\n" + + "\x13UpdateFrameTemplate\x12%.frames.v1.UpdateFrameTemplateRequest\x1a&.frames.v1.UpdateFrameTemplateResponse\x12d\n" + + "\x13DeleteFrameTemplate\x12%.frames.v1.DeleteFrameTemplateRequest\x1a&.frames.v1.DeleteFrameTemplateResponse\x12U\n" + "\x0eListOrgMembers\x12 .frames.v1.ListOrgMembersRequest\x1a!.frames.v1.ListOrgMembersResponse\x12O\n" + "\fAddOrgMember\x12\x1e.frames.v1.AddOrgMemberRequest\x1a\x1f.frames.v1.AddOrgMemberResponse\x12R\n" + "\rSetMemberRole\x12\x1f.frames.v1.SetMemberRoleRequest\x1a .frames.v1.SetMemberRoleResponse\x12X\n" + @@ -1525,87 +2069,120 @@ func file_frames_v1_frame_service_proto_rawDescGZIP() []byte { return file_frames_v1_frame_service_proto_rawDescData } -var file_frames_v1_frame_service_proto_msgTypes = make([]protoimpl.MessageInfo, 27) +var file_frames_v1_frame_service_proto_msgTypes = make([]protoimpl.MessageInfo, 39) var file_frames_v1_frame_service_proto_goTypes = []any{ - (*PublishFrameRequest)(nil), // 0: frames.v1.PublishFrameRequest - (*PublishFrameResponse)(nil), // 1: frames.v1.PublishFrameResponse - (*ListFramesRequest)(nil), // 2: frames.v1.ListFramesRequest - (*ListFramesResponse)(nil), // 3: frames.v1.ListFramesResponse - (*GetFrameRequest)(nil), // 4: frames.v1.GetFrameRequest - (*GetFrameResponse)(nil), // 5: frames.v1.GetFrameResponse - (*ResolveFrameRequest)(nil), // 6: frames.v1.ResolveFrameRequest - (*ResolveFrameResponse)(nil), // 7: frames.v1.ResolveFrameResponse - (*GetMeRequest)(nil), // 8: frames.v1.GetMeRequest - (*GetMeResponse)(nil), // 9: frames.v1.GetMeResponse - (*ListFrameVersionsRequest)(nil), // 10: frames.v1.ListFrameVersionsRequest - (*ListFrameVersionsResponse)(nil), // 11: frames.v1.ListFrameVersionsResponse - (*FieldViolation)(nil), // 12: frames.v1.FieldViolation - (*FieldViolations)(nil), // 13: frames.v1.FieldViolations - (*ConvertFrameRequest)(nil), // 14: frames.v1.ConvertFrameRequest - (*ConvertFrameResponse)(nil), // 15: frames.v1.ConvertFrameResponse - (*DeleteFrameRequest)(nil), // 16: frames.v1.DeleteFrameRequest - (*DeleteFrameResponse)(nil), // 17: frames.v1.DeleteFrameResponse - (*DeleteBlocked)(nil), // 18: frames.v1.DeleteBlocked - (*ListOrgMembersRequest)(nil), // 19: frames.v1.ListOrgMembersRequest - (*ListOrgMembersResponse)(nil), // 20: frames.v1.ListOrgMembersResponse - (*AddOrgMemberRequest)(nil), // 21: frames.v1.AddOrgMemberRequest - (*AddOrgMemberResponse)(nil), // 22: frames.v1.AddOrgMemberResponse - (*SetMemberRoleRequest)(nil), // 23: frames.v1.SetMemberRoleRequest - (*SetMemberRoleResponse)(nil), // 24: frames.v1.SetMemberRoleResponse - (*RemoveOrgMemberRequest)(nil), // 25: frames.v1.RemoveOrgMemberRequest - (*RemoveOrgMemberResponse)(nil), // 26: frames.v1.RemoveOrgMemberResponse - (*Frame)(nil), // 27: frames.v1.Frame - (*FrameVersion)(nil), // 28: frames.v1.FrameVersion - (*FrameSummary)(nil), // 29: frames.v1.FrameSummary - (*ParentRef)(nil), // 30: frames.v1.ParentRef - (*Permissions)(nil), // 31: frames.v1.Permissions - (*Org)(nil), // 32: frames.v1.Org - (*FrameVersionSummary)(nil), // 33: frames.v1.FrameVersionSummary - (*Membership)(nil), // 34: frames.v1.Membership + (*PublishFrameRequest)(nil), // 0: frames.v1.PublishFrameRequest + (*PublishFrameResponse)(nil), // 1: frames.v1.PublishFrameResponse + (*ListFramesRequest)(nil), // 2: frames.v1.ListFramesRequest + (*ListFramesResponse)(nil), // 3: frames.v1.ListFramesResponse + (*GetFrameRequest)(nil), // 4: frames.v1.GetFrameRequest + (*GetFrameResponse)(nil), // 5: frames.v1.GetFrameResponse + (*ResolveFrameRequest)(nil), // 6: frames.v1.ResolveFrameRequest + (*ResolveFrameResponse)(nil), // 7: frames.v1.ResolveFrameResponse + (*GetMeRequest)(nil), // 8: frames.v1.GetMeRequest + (*GetMeResponse)(nil), // 9: frames.v1.GetMeResponse + (*ListFrameVersionsRequest)(nil), // 10: frames.v1.ListFrameVersionsRequest + (*ListFrameVersionsResponse)(nil), // 11: frames.v1.ListFrameVersionsResponse + (*FieldViolation)(nil), // 12: frames.v1.FieldViolation + (*FieldViolations)(nil), // 13: frames.v1.FieldViolations + (*ConvertFrameRequest)(nil), // 14: frames.v1.ConvertFrameRequest + (*ConvertFrameResponse)(nil), // 15: frames.v1.ConvertFrameResponse + (*DeleteFrameRequest)(nil), // 16: frames.v1.DeleteFrameRequest + (*DeleteFrameResponse)(nil), // 17: frames.v1.DeleteFrameResponse + (*DeleteBlocked)(nil), // 18: frames.v1.DeleteBlocked + (*ListOrgMembersRequest)(nil), // 19: frames.v1.ListOrgMembersRequest + (*ListOrgMembersResponse)(nil), // 20: frames.v1.ListOrgMembersResponse + (*AddOrgMemberRequest)(nil), // 21: frames.v1.AddOrgMemberRequest + (*AddOrgMemberResponse)(nil), // 22: frames.v1.AddOrgMemberResponse + (*SetMemberRoleRequest)(nil), // 23: frames.v1.SetMemberRoleRequest + (*SetMemberRoleResponse)(nil), // 24: frames.v1.SetMemberRoleResponse + (*RemoveOrgMemberRequest)(nil), // 25: frames.v1.RemoveOrgMemberRequest + (*RemoveOrgMemberResponse)(nil), // 26: frames.v1.RemoveOrgMemberResponse + (*ListFrameTemplatesRequest)(nil), // 27: frames.v1.ListFrameTemplatesRequest + (*ListFrameTemplatesResponse)(nil), // 28: frames.v1.ListFrameTemplatesResponse + (*GetFrameTemplateRequest)(nil), // 29: frames.v1.GetFrameTemplateRequest + (*GetFrameTemplateResponse)(nil), // 30: frames.v1.GetFrameTemplateResponse + (*CreateFrameTemplateRequest)(nil), // 31: frames.v1.CreateFrameTemplateRequest + (*CreateFrameTemplateResponse)(nil), // 32: frames.v1.CreateFrameTemplateResponse + (*UpdateFrameTemplateRequest)(nil), // 33: frames.v1.UpdateFrameTemplateRequest + (*UpdateFrameTemplateResponse)(nil), // 34: frames.v1.UpdateFrameTemplateResponse + (*DeleteFrameTemplateRequest)(nil), // 35: frames.v1.DeleteFrameTemplateRequest + (*DeleteFrameTemplateResponse)(nil), // 36: frames.v1.DeleteFrameTemplateResponse + nil, // 37: frames.v1.CreateFrameTemplateRequest.FieldRulesEntry + nil, // 38: frames.v1.UpdateFrameTemplateRequest.FieldRulesEntry + (*Frame)(nil), // 39: frames.v1.Frame + (*FrameVersion)(nil), // 40: frames.v1.FrameVersion + (*FrameSummary)(nil), // 41: frames.v1.FrameSummary + (*ParentRef)(nil), // 42: frames.v1.ParentRef + (*Permissions)(nil), // 43: frames.v1.Permissions + (*Org)(nil), // 44: frames.v1.Org + (*FrameVersionSummary)(nil), // 45: frames.v1.FrameVersionSummary + (*Membership)(nil), // 46: frames.v1.Membership + (*FrameTemplateSummary)(nil), // 47: frames.v1.FrameTemplateSummary + (*FrameTemplate)(nil), // 48: frames.v1.FrameTemplate + (*FieldRule)(nil), // 49: frames.v1.FieldRule } var file_frames_v1_frame_service_proto_depIdxs = []int32{ - 27, // 0: frames.v1.PublishFrameResponse.frame:type_name -> frames.v1.Frame - 28, // 1: frames.v1.PublishFrameResponse.version:type_name -> frames.v1.FrameVersion - 29, // 2: frames.v1.ListFramesResponse.frames:type_name -> frames.v1.FrameSummary - 27, // 3: frames.v1.GetFrameResponse.frame:type_name -> frames.v1.Frame - 28, // 4: frames.v1.GetFrameResponse.version:type_name -> frames.v1.FrameVersion - 30, // 5: frames.v1.GetFrameResponse.extends:type_name -> frames.v1.ParentRef - 31, // 6: frames.v1.GetFrameResponse.permissions:type_name -> frames.v1.Permissions - 32, // 7: frames.v1.GetMeResponse.org:type_name -> frames.v1.Org - 33, // 8: frames.v1.ListFrameVersionsResponse.versions:type_name -> frames.v1.FrameVersionSummary + 39, // 0: frames.v1.PublishFrameResponse.frame:type_name -> frames.v1.Frame + 40, // 1: frames.v1.PublishFrameResponse.version:type_name -> frames.v1.FrameVersion + 41, // 2: frames.v1.ListFramesResponse.frames:type_name -> frames.v1.FrameSummary + 39, // 3: frames.v1.GetFrameResponse.frame:type_name -> frames.v1.Frame + 40, // 4: frames.v1.GetFrameResponse.version:type_name -> frames.v1.FrameVersion + 42, // 5: frames.v1.GetFrameResponse.extends:type_name -> frames.v1.ParentRef + 43, // 6: frames.v1.GetFrameResponse.permissions:type_name -> frames.v1.Permissions + 44, // 7: frames.v1.GetMeResponse.org:type_name -> frames.v1.Org + 45, // 8: frames.v1.ListFrameVersionsResponse.versions:type_name -> frames.v1.FrameVersionSummary 12, // 9: frames.v1.FieldViolations.violations:type_name -> frames.v1.FieldViolation - 34, // 10: frames.v1.ListOrgMembersResponse.members:type_name -> frames.v1.Membership - 34, // 11: frames.v1.AddOrgMemberResponse.member:type_name -> frames.v1.Membership - 34, // 12: frames.v1.SetMemberRoleResponse.member:type_name -> frames.v1.Membership - 0, // 13: frames.v1.FrameService.PublishFrame:input_type -> frames.v1.PublishFrameRequest - 2, // 14: frames.v1.FrameService.ListFrames:input_type -> frames.v1.ListFramesRequest - 4, // 15: frames.v1.FrameService.GetFrame:input_type -> frames.v1.GetFrameRequest - 6, // 16: frames.v1.FrameService.ResolveFrame:input_type -> frames.v1.ResolveFrameRequest - 8, // 17: frames.v1.FrameService.GetMe:input_type -> frames.v1.GetMeRequest - 10, // 18: frames.v1.FrameService.ListFrameVersions:input_type -> frames.v1.ListFrameVersionsRequest - 16, // 19: frames.v1.FrameService.DeleteFrame:input_type -> frames.v1.DeleteFrameRequest - 14, // 20: frames.v1.FrameService.ConvertFrame:input_type -> frames.v1.ConvertFrameRequest - 19, // 21: frames.v1.FrameService.ListOrgMembers:input_type -> frames.v1.ListOrgMembersRequest - 21, // 22: frames.v1.FrameService.AddOrgMember:input_type -> frames.v1.AddOrgMemberRequest - 23, // 23: frames.v1.FrameService.SetMemberRole:input_type -> frames.v1.SetMemberRoleRequest - 25, // 24: frames.v1.FrameService.RemoveOrgMember:input_type -> frames.v1.RemoveOrgMemberRequest - 1, // 25: frames.v1.FrameService.PublishFrame:output_type -> frames.v1.PublishFrameResponse - 3, // 26: frames.v1.FrameService.ListFrames:output_type -> frames.v1.ListFramesResponse - 5, // 27: frames.v1.FrameService.GetFrame:output_type -> frames.v1.GetFrameResponse - 7, // 28: frames.v1.FrameService.ResolveFrame:output_type -> frames.v1.ResolveFrameResponse - 9, // 29: frames.v1.FrameService.GetMe:output_type -> frames.v1.GetMeResponse - 11, // 30: frames.v1.FrameService.ListFrameVersions:output_type -> frames.v1.ListFrameVersionsResponse - 17, // 31: frames.v1.FrameService.DeleteFrame:output_type -> frames.v1.DeleteFrameResponse - 15, // 32: frames.v1.FrameService.ConvertFrame:output_type -> frames.v1.ConvertFrameResponse - 20, // 33: frames.v1.FrameService.ListOrgMembers:output_type -> frames.v1.ListOrgMembersResponse - 22, // 34: frames.v1.FrameService.AddOrgMember:output_type -> frames.v1.AddOrgMemberResponse - 24, // 35: frames.v1.FrameService.SetMemberRole:output_type -> frames.v1.SetMemberRoleResponse - 26, // 36: frames.v1.FrameService.RemoveOrgMember:output_type -> frames.v1.RemoveOrgMemberResponse - 25, // [25:37] is the sub-list for method output_type - 13, // [13:25] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 46, // 10: frames.v1.ListOrgMembersResponse.members:type_name -> frames.v1.Membership + 46, // 11: frames.v1.AddOrgMemberResponse.member:type_name -> frames.v1.Membership + 46, // 12: frames.v1.SetMemberRoleResponse.member:type_name -> frames.v1.Membership + 47, // 13: frames.v1.ListFrameTemplatesResponse.templates:type_name -> frames.v1.FrameTemplateSummary + 48, // 14: frames.v1.GetFrameTemplateResponse.template:type_name -> frames.v1.FrameTemplate + 37, // 15: frames.v1.CreateFrameTemplateRequest.field_rules:type_name -> frames.v1.CreateFrameTemplateRequest.FieldRulesEntry + 48, // 16: frames.v1.CreateFrameTemplateResponse.template:type_name -> frames.v1.FrameTemplate + 38, // 17: frames.v1.UpdateFrameTemplateRequest.field_rules:type_name -> frames.v1.UpdateFrameTemplateRequest.FieldRulesEntry + 48, // 18: frames.v1.UpdateFrameTemplateResponse.template:type_name -> frames.v1.FrameTemplate + 49, // 19: frames.v1.CreateFrameTemplateRequest.FieldRulesEntry.value:type_name -> frames.v1.FieldRule + 49, // 20: frames.v1.UpdateFrameTemplateRequest.FieldRulesEntry.value:type_name -> frames.v1.FieldRule + 0, // 21: frames.v1.FrameService.PublishFrame:input_type -> frames.v1.PublishFrameRequest + 2, // 22: frames.v1.FrameService.ListFrames:input_type -> frames.v1.ListFramesRequest + 4, // 23: frames.v1.FrameService.GetFrame:input_type -> frames.v1.GetFrameRequest + 6, // 24: frames.v1.FrameService.ResolveFrame:input_type -> frames.v1.ResolveFrameRequest + 8, // 25: frames.v1.FrameService.GetMe:input_type -> frames.v1.GetMeRequest + 10, // 26: frames.v1.FrameService.ListFrameVersions:input_type -> frames.v1.ListFrameVersionsRequest + 16, // 27: frames.v1.FrameService.DeleteFrame:input_type -> frames.v1.DeleteFrameRequest + 14, // 28: frames.v1.FrameService.ConvertFrame:input_type -> frames.v1.ConvertFrameRequest + 27, // 29: frames.v1.FrameService.ListFrameTemplates:input_type -> frames.v1.ListFrameTemplatesRequest + 29, // 30: frames.v1.FrameService.GetFrameTemplate:input_type -> frames.v1.GetFrameTemplateRequest + 31, // 31: frames.v1.FrameService.CreateFrameTemplate:input_type -> frames.v1.CreateFrameTemplateRequest + 33, // 32: frames.v1.FrameService.UpdateFrameTemplate:input_type -> frames.v1.UpdateFrameTemplateRequest + 35, // 33: frames.v1.FrameService.DeleteFrameTemplate:input_type -> frames.v1.DeleteFrameTemplateRequest + 19, // 34: frames.v1.FrameService.ListOrgMembers:input_type -> frames.v1.ListOrgMembersRequest + 21, // 35: frames.v1.FrameService.AddOrgMember:input_type -> frames.v1.AddOrgMemberRequest + 23, // 36: frames.v1.FrameService.SetMemberRole:input_type -> frames.v1.SetMemberRoleRequest + 25, // 37: frames.v1.FrameService.RemoveOrgMember:input_type -> frames.v1.RemoveOrgMemberRequest + 1, // 38: frames.v1.FrameService.PublishFrame:output_type -> frames.v1.PublishFrameResponse + 3, // 39: frames.v1.FrameService.ListFrames:output_type -> frames.v1.ListFramesResponse + 5, // 40: frames.v1.FrameService.GetFrame:output_type -> frames.v1.GetFrameResponse + 7, // 41: frames.v1.FrameService.ResolveFrame:output_type -> frames.v1.ResolveFrameResponse + 9, // 42: frames.v1.FrameService.GetMe:output_type -> frames.v1.GetMeResponse + 11, // 43: frames.v1.FrameService.ListFrameVersions:output_type -> frames.v1.ListFrameVersionsResponse + 17, // 44: frames.v1.FrameService.DeleteFrame:output_type -> frames.v1.DeleteFrameResponse + 15, // 45: frames.v1.FrameService.ConvertFrame:output_type -> frames.v1.ConvertFrameResponse + 28, // 46: frames.v1.FrameService.ListFrameTemplates:output_type -> frames.v1.ListFrameTemplatesResponse + 30, // 47: frames.v1.FrameService.GetFrameTemplate:output_type -> frames.v1.GetFrameTemplateResponse + 32, // 48: frames.v1.FrameService.CreateFrameTemplate:output_type -> frames.v1.CreateFrameTemplateResponse + 34, // 49: frames.v1.FrameService.UpdateFrameTemplate:output_type -> frames.v1.UpdateFrameTemplateResponse + 36, // 50: frames.v1.FrameService.DeleteFrameTemplate:output_type -> frames.v1.DeleteFrameTemplateResponse + 20, // 51: frames.v1.FrameService.ListOrgMembers:output_type -> frames.v1.ListOrgMembersResponse + 22, // 52: frames.v1.FrameService.AddOrgMember:output_type -> frames.v1.AddOrgMemberResponse + 24, // 53: frames.v1.FrameService.SetMemberRole:output_type -> frames.v1.SetMemberRoleResponse + 26, // 54: frames.v1.FrameService.RemoveOrgMember:output_type -> frames.v1.RemoveOrgMemberResponse + 38, // [38:55] is the sub-list for method output_type + 21, // [21:38] is the sub-list for method input_type + 21, // [21:21] is the sub-list for extension type_name + 21, // [21:21] is the sub-list for extension extendee + 0, // [0:21] is the sub-list for field type_name } func init() { file_frames_v1_frame_service_proto_init() } @@ -1624,7 +2201,7 @@ func file_frames_v1_frame_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_frames_v1_frame_service_proto_rawDesc), len(file_frames_v1_frame_service_proto_rawDesc)), NumEnums: 0, - NumMessages: 27, + NumMessages: 39, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/go/frames/v1/framesv1connect/frame_service.connect.go b/gen/go/frames/v1/framesv1connect/frame_service.connect.go index 4c4c754..9fb6bfd 100644 --- a/gen/go/frames/v1/framesv1connect/frame_service.connect.go +++ b/gen/go/frames/v1/framesv1connect/frame_service.connect.go @@ -54,6 +54,21 @@ const ( // FrameServiceConvertFrameProcedure is the fully-qualified name of the FrameService's ConvertFrame // RPC. FrameServiceConvertFrameProcedure = "/frames.v1.FrameService/ConvertFrame" + // FrameServiceListFrameTemplatesProcedure is the fully-qualified name of the FrameService's + // ListFrameTemplates RPC. + FrameServiceListFrameTemplatesProcedure = "/frames.v1.FrameService/ListFrameTemplates" + // FrameServiceGetFrameTemplateProcedure is the fully-qualified name of the FrameService's + // GetFrameTemplate RPC. + FrameServiceGetFrameTemplateProcedure = "/frames.v1.FrameService/GetFrameTemplate" + // FrameServiceCreateFrameTemplateProcedure is the fully-qualified name of the FrameService's + // CreateFrameTemplate RPC. + FrameServiceCreateFrameTemplateProcedure = "/frames.v1.FrameService/CreateFrameTemplate" + // FrameServiceUpdateFrameTemplateProcedure is the fully-qualified name of the FrameService's + // UpdateFrameTemplate RPC. + FrameServiceUpdateFrameTemplateProcedure = "/frames.v1.FrameService/UpdateFrameTemplate" + // FrameServiceDeleteFrameTemplateProcedure is the fully-qualified name of the FrameService's + // DeleteFrameTemplate RPC. + FrameServiceDeleteFrameTemplateProcedure = "/frames.v1.FrameService/DeleteFrameTemplate" // FrameServiceListOrgMembersProcedure is the fully-qualified name of the FrameService's // ListOrgMembers RPC. FrameServiceListOrgMembersProcedure = "/frames.v1.FrameService/ListOrgMembers" @@ -87,6 +102,20 @@ type FrameServiceClient interface { // Pure conversion between the canonical slot YAML and the spec-conformant // .frame.md form. Stateless and unauthenticated beyond org membership. ConvertFrame(context.Context, *connect.Request[v1.ConvertFrameRequest]) (*connect.Response[v1.ConvertFrameResponse], error) + // Read - the templates the caller may start a Frame from: the built-ins plus + // their own org's. Any member may call it. + ListFrameTemplates(context.Context, *connect.Request[v1.ListFrameTemplatesRequest]) (*connect.Response[v1.ListFrameTemplatesResponse], error) + // Read - one template with its prefill and rules. 404 for a built-in that + // does not exist and for another org's template alike, so existence does not + // leak. + GetFrameTemplate(context.Context, *connect.Request[v1.GetFrameTemplateRequest]) (*connect.Response[v1.GetFrameTemplateResponse], error) + // Admin only - create an org template. + CreateFrameTemplate(context.Context, *connect.Request[v1.CreateFrameTemplateRequest]) (*connect.Response[v1.CreateFrameTemplateResponse], error) + // Admin only - replace an org template's content. Built-ins are immutable. + UpdateFrameTemplate(context.Context, *connect.Request[v1.UpdateFrameTemplateRequest]) (*connect.Response[v1.UpdateFrameTemplateResponse], error) + // Admin only - delete an org template. Frames already created from it are + // unaffected: a template is copied once and never referenced again. + DeleteFrameTemplate(context.Context, *connect.Request[v1.DeleteFrameTemplateRequest]) (*connect.Response[v1.DeleteFrameTemplateResponse], error) // Admin only - list the caller's org members. ListOrgMembers(context.Context, *connect.Request[v1.ListOrgMembersRequest]) (*connect.Response[v1.ListOrgMembersResponse], error) // Admin only - add a member to the caller's org by email (pending until login). @@ -156,6 +185,36 @@ func NewFrameServiceClient(httpClient connect.HTTPClient, baseURL string, opts . connect.WithSchema(frameServiceMethods.ByName("ConvertFrame")), connect.WithClientOptions(opts...), ), + listFrameTemplates: connect.NewClient[v1.ListFrameTemplatesRequest, v1.ListFrameTemplatesResponse]( + httpClient, + baseURL+FrameServiceListFrameTemplatesProcedure, + connect.WithSchema(frameServiceMethods.ByName("ListFrameTemplates")), + connect.WithClientOptions(opts...), + ), + getFrameTemplate: connect.NewClient[v1.GetFrameTemplateRequest, v1.GetFrameTemplateResponse]( + httpClient, + baseURL+FrameServiceGetFrameTemplateProcedure, + connect.WithSchema(frameServiceMethods.ByName("GetFrameTemplate")), + connect.WithClientOptions(opts...), + ), + createFrameTemplate: connect.NewClient[v1.CreateFrameTemplateRequest, v1.CreateFrameTemplateResponse]( + httpClient, + baseURL+FrameServiceCreateFrameTemplateProcedure, + connect.WithSchema(frameServiceMethods.ByName("CreateFrameTemplate")), + connect.WithClientOptions(opts...), + ), + updateFrameTemplate: connect.NewClient[v1.UpdateFrameTemplateRequest, v1.UpdateFrameTemplateResponse]( + httpClient, + baseURL+FrameServiceUpdateFrameTemplateProcedure, + connect.WithSchema(frameServiceMethods.ByName("UpdateFrameTemplate")), + connect.WithClientOptions(opts...), + ), + deleteFrameTemplate: connect.NewClient[v1.DeleteFrameTemplateRequest, v1.DeleteFrameTemplateResponse]( + httpClient, + baseURL+FrameServiceDeleteFrameTemplateProcedure, + connect.WithSchema(frameServiceMethods.ByName("DeleteFrameTemplate")), + connect.WithClientOptions(opts...), + ), listOrgMembers: connect.NewClient[v1.ListOrgMembersRequest, v1.ListOrgMembersResponse]( httpClient, baseURL+FrameServiceListOrgMembersProcedure, @@ -185,18 +244,23 @@ func NewFrameServiceClient(httpClient connect.HTTPClient, baseURL string, opts . // frameServiceClient implements FrameServiceClient. type frameServiceClient struct { - publishFrame *connect.Client[v1.PublishFrameRequest, v1.PublishFrameResponse] - listFrames *connect.Client[v1.ListFramesRequest, v1.ListFramesResponse] - getFrame *connect.Client[v1.GetFrameRequest, v1.GetFrameResponse] - resolveFrame *connect.Client[v1.ResolveFrameRequest, v1.ResolveFrameResponse] - getMe *connect.Client[v1.GetMeRequest, v1.GetMeResponse] - listFrameVersions *connect.Client[v1.ListFrameVersionsRequest, v1.ListFrameVersionsResponse] - deleteFrame *connect.Client[v1.DeleteFrameRequest, v1.DeleteFrameResponse] - convertFrame *connect.Client[v1.ConvertFrameRequest, v1.ConvertFrameResponse] - listOrgMembers *connect.Client[v1.ListOrgMembersRequest, v1.ListOrgMembersResponse] - addOrgMember *connect.Client[v1.AddOrgMemberRequest, v1.AddOrgMemberResponse] - setMemberRole *connect.Client[v1.SetMemberRoleRequest, v1.SetMemberRoleResponse] - removeOrgMember *connect.Client[v1.RemoveOrgMemberRequest, v1.RemoveOrgMemberResponse] + publishFrame *connect.Client[v1.PublishFrameRequest, v1.PublishFrameResponse] + listFrames *connect.Client[v1.ListFramesRequest, v1.ListFramesResponse] + getFrame *connect.Client[v1.GetFrameRequest, v1.GetFrameResponse] + resolveFrame *connect.Client[v1.ResolveFrameRequest, v1.ResolveFrameResponse] + getMe *connect.Client[v1.GetMeRequest, v1.GetMeResponse] + listFrameVersions *connect.Client[v1.ListFrameVersionsRequest, v1.ListFrameVersionsResponse] + deleteFrame *connect.Client[v1.DeleteFrameRequest, v1.DeleteFrameResponse] + convertFrame *connect.Client[v1.ConvertFrameRequest, v1.ConvertFrameResponse] + listFrameTemplates *connect.Client[v1.ListFrameTemplatesRequest, v1.ListFrameTemplatesResponse] + getFrameTemplate *connect.Client[v1.GetFrameTemplateRequest, v1.GetFrameTemplateResponse] + createFrameTemplate *connect.Client[v1.CreateFrameTemplateRequest, v1.CreateFrameTemplateResponse] + updateFrameTemplate *connect.Client[v1.UpdateFrameTemplateRequest, v1.UpdateFrameTemplateResponse] + deleteFrameTemplate *connect.Client[v1.DeleteFrameTemplateRequest, v1.DeleteFrameTemplateResponse] + listOrgMembers *connect.Client[v1.ListOrgMembersRequest, v1.ListOrgMembersResponse] + addOrgMember *connect.Client[v1.AddOrgMemberRequest, v1.AddOrgMemberResponse] + setMemberRole *connect.Client[v1.SetMemberRoleRequest, v1.SetMemberRoleResponse] + removeOrgMember *connect.Client[v1.RemoveOrgMemberRequest, v1.RemoveOrgMemberResponse] } // PublishFrame calls frames.v1.FrameService.PublishFrame. @@ -239,6 +303,31 @@ func (c *frameServiceClient) ConvertFrame(ctx context.Context, req *connect.Requ return c.convertFrame.CallUnary(ctx, req) } +// ListFrameTemplates calls frames.v1.FrameService.ListFrameTemplates. +func (c *frameServiceClient) ListFrameTemplates(ctx context.Context, req *connect.Request[v1.ListFrameTemplatesRequest]) (*connect.Response[v1.ListFrameTemplatesResponse], error) { + return c.listFrameTemplates.CallUnary(ctx, req) +} + +// GetFrameTemplate calls frames.v1.FrameService.GetFrameTemplate. +func (c *frameServiceClient) GetFrameTemplate(ctx context.Context, req *connect.Request[v1.GetFrameTemplateRequest]) (*connect.Response[v1.GetFrameTemplateResponse], error) { + return c.getFrameTemplate.CallUnary(ctx, req) +} + +// CreateFrameTemplate calls frames.v1.FrameService.CreateFrameTemplate. +func (c *frameServiceClient) CreateFrameTemplate(ctx context.Context, req *connect.Request[v1.CreateFrameTemplateRequest]) (*connect.Response[v1.CreateFrameTemplateResponse], error) { + return c.createFrameTemplate.CallUnary(ctx, req) +} + +// UpdateFrameTemplate calls frames.v1.FrameService.UpdateFrameTemplate. +func (c *frameServiceClient) UpdateFrameTemplate(ctx context.Context, req *connect.Request[v1.UpdateFrameTemplateRequest]) (*connect.Response[v1.UpdateFrameTemplateResponse], error) { + return c.updateFrameTemplate.CallUnary(ctx, req) +} + +// DeleteFrameTemplate calls frames.v1.FrameService.DeleteFrameTemplate. +func (c *frameServiceClient) DeleteFrameTemplate(ctx context.Context, req *connect.Request[v1.DeleteFrameTemplateRequest]) (*connect.Response[v1.DeleteFrameTemplateResponse], error) { + return c.deleteFrameTemplate.CallUnary(ctx, req) +} + // ListOrgMembers calls frames.v1.FrameService.ListOrgMembers. func (c *frameServiceClient) ListOrgMembers(ctx context.Context, req *connect.Request[v1.ListOrgMembersRequest]) (*connect.Response[v1.ListOrgMembersResponse], error) { return c.listOrgMembers.CallUnary(ctx, req) @@ -278,6 +367,20 @@ type FrameServiceHandler interface { // Pure conversion between the canonical slot YAML and the spec-conformant // .frame.md form. Stateless and unauthenticated beyond org membership. ConvertFrame(context.Context, *connect.Request[v1.ConvertFrameRequest]) (*connect.Response[v1.ConvertFrameResponse], error) + // Read - the templates the caller may start a Frame from: the built-ins plus + // their own org's. Any member may call it. + ListFrameTemplates(context.Context, *connect.Request[v1.ListFrameTemplatesRequest]) (*connect.Response[v1.ListFrameTemplatesResponse], error) + // Read - one template with its prefill and rules. 404 for a built-in that + // does not exist and for another org's template alike, so existence does not + // leak. + GetFrameTemplate(context.Context, *connect.Request[v1.GetFrameTemplateRequest]) (*connect.Response[v1.GetFrameTemplateResponse], error) + // Admin only - create an org template. + CreateFrameTemplate(context.Context, *connect.Request[v1.CreateFrameTemplateRequest]) (*connect.Response[v1.CreateFrameTemplateResponse], error) + // Admin only - replace an org template's content. Built-ins are immutable. + UpdateFrameTemplate(context.Context, *connect.Request[v1.UpdateFrameTemplateRequest]) (*connect.Response[v1.UpdateFrameTemplateResponse], error) + // Admin only - delete an org template. Frames already created from it are + // unaffected: a template is copied once and never referenced again. + DeleteFrameTemplate(context.Context, *connect.Request[v1.DeleteFrameTemplateRequest]) (*connect.Response[v1.DeleteFrameTemplateResponse], error) // Admin only - list the caller's org members. ListOrgMembers(context.Context, *connect.Request[v1.ListOrgMembersRequest]) (*connect.Response[v1.ListOrgMembersResponse], error) // Admin only - add a member to the caller's org by email (pending until login). @@ -343,6 +446,36 @@ func NewFrameServiceHandler(svc FrameServiceHandler, opts ...connect.HandlerOpti connect.WithSchema(frameServiceMethods.ByName("ConvertFrame")), connect.WithHandlerOptions(opts...), ) + frameServiceListFrameTemplatesHandler := connect.NewUnaryHandler( + FrameServiceListFrameTemplatesProcedure, + svc.ListFrameTemplates, + connect.WithSchema(frameServiceMethods.ByName("ListFrameTemplates")), + connect.WithHandlerOptions(opts...), + ) + frameServiceGetFrameTemplateHandler := connect.NewUnaryHandler( + FrameServiceGetFrameTemplateProcedure, + svc.GetFrameTemplate, + connect.WithSchema(frameServiceMethods.ByName("GetFrameTemplate")), + connect.WithHandlerOptions(opts...), + ) + frameServiceCreateFrameTemplateHandler := connect.NewUnaryHandler( + FrameServiceCreateFrameTemplateProcedure, + svc.CreateFrameTemplate, + connect.WithSchema(frameServiceMethods.ByName("CreateFrameTemplate")), + connect.WithHandlerOptions(opts...), + ) + frameServiceUpdateFrameTemplateHandler := connect.NewUnaryHandler( + FrameServiceUpdateFrameTemplateProcedure, + svc.UpdateFrameTemplate, + connect.WithSchema(frameServiceMethods.ByName("UpdateFrameTemplate")), + connect.WithHandlerOptions(opts...), + ) + frameServiceDeleteFrameTemplateHandler := connect.NewUnaryHandler( + FrameServiceDeleteFrameTemplateProcedure, + svc.DeleteFrameTemplate, + connect.WithSchema(frameServiceMethods.ByName("DeleteFrameTemplate")), + connect.WithHandlerOptions(opts...), + ) frameServiceListOrgMembersHandler := connect.NewUnaryHandler( FrameServiceListOrgMembersProcedure, svc.ListOrgMembers, @@ -385,6 +518,16 @@ func NewFrameServiceHandler(svc FrameServiceHandler, opts ...connect.HandlerOpti frameServiceDeleteFrameHandler.ServeHTTP(w, r) case FrameServiceConvertFrameProcedure: frameServiceConvertFrameHandler.ServeHTTP(w, r) + case FrameServiceListFrameTemplatesProcedure: + frameServiceListFrameTemplatesHandler.ServeHTTP(w, r) + case FrameServiceGetFrameTemplateProcedure: + frameServiceGetFrameTemplateHandler.ServeHTTP(w, r) + case FrameServiceCreateFrameTemplateProcedure: + frameServiceCreateFrameTemplateHandler.ServeHTTP(w, r) + case FrameServiceUpdateFrameTemplateProcedure: + frameServiceUpdateFrameTemplateHandler.ServeHTTP(w, r) + case FrameServiceDeleteFrameTemplateProcedure: + frameServiceDeleteFrameTemplateHandler.ServeHTTP(w, r) case FrameServiceListOrgMembersProcedure: frameServiceListOrgMembersHandler.ServeHTTP(w, r) case FrameServiceAddOrgMemberProcedure: @@ -434,6 +577,26 @@ func (UnimplementedFrameServiceHandler) ConvertFrame(context.Context, *connect.R return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.ConvertFrame is not implemented")) } +func (UnimplementedFrameServiceHandler) ListFrameTemplates(context.Context, *connect.Request[v1.ListFrameTemplatesRequest]) (*connect.Response[v1.ListFrameTemplatesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.ListFrameTemplates is not implemented")) +} + +func (UnimplementedFrameServiceHandler) GetFrameTemplate(context.Context, *connect.Request[v1.GetFrameTemplateRequest]) (*connect.Response[v1.GetFrameTemplateResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.GetFrameTemplate is not implemented")) +} + +func (UnimplementedFrameServiceHandler) CreateFrameTemplate(context.Context, *connect.Request[v1.CreateFrameTemplateRequest]) (*connect.Response[v1.CreateFrameTemplateResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.CreateFrameTemplate is not implemented")) +} + +func (UnimplementedFrameServiceHandler) UpdateFrameTemplate(context.Context, *connect.Request[v1.UpdateFrameTemplateRequest]) (*connect.Response[v1.UpdateFrameTemplateResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.UpdateFrameTemplate is not implemented")) +} + +func (UnimplementedFrameServiceHandler) DeleteFrameTemplate(context.Context, *connect.Request[v1.DeleteFrameTemplateRequest]) (*connect.Response[v1.DeleteFrameTemplateResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.DeleteFrameTemplate is not implemented")) +} + func (UnimplementedFrameServiceHandler) ListOrgMembers(context.Context, *connect.Request[v1.ListOrgMembersRequest]) (*connect.Response[v1.ListOrgMembersResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.ListOrgMembers is not implemented")) } diff --git a/gen/ts/frames/v1/frame_pb.ts b/gen/ts/frames/v1/frame_pb.ts index 90e4dd1..49d8676 100644 --- a/gen/ts/frames/v1/frame_pb.ts +++ b/gen/ts/frames/v1/frame_pb.ts @@ -2,8 +2,8 @@ // @generated from file frames/v1/frame.proto (package frames.v1, syntax proto3) /* eslint-disable */ -import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; -import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; import type { Timestamp } from "@bufbuild/protobuf/wkt"; import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; import type { Message } from "@bufbuild/protobuf"; @@ -12,7 +12,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file frames/v1/frame.proto. */ export const file_frames_v1_frame: GenFile = /*@__PURE__*/ - fileDesc("ChVmcmFtZXMvdjEvZnJhbWUucHJvdG8SCWZyYW1lcy52MSJlCgNPcmcSCgoCaWQYASABKAkSDAoEc2x1ZxgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSLgoKY3JlYXRlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAieQoKTWVtYmVyc2hpcBIOCgZvcmdfaWQYASABKAkSEAoIdXNlcl9zdWIYAiABKAkSDAoEcm9sZRgDIAEoCRIsCghhZGRlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDQoFZW1haWwYBSABKAki0QEKBUZyYW1lEgoKAmlkGAEgASgJEg4KBm9yZ19pZBgCIAEoCRIMCgRuYW1lGAMgASgJEhMKC2Rlc2NyaXB0aW9uGAQgASgJEhEKCW93bmVyX3N1YhgFIAEoCRIWCg5sYXRlc3RfdmVyc2lvbhgGIAEoCRIuCgpjcmVhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIpCglQYXJlbnRSZWYSCwoDcmVmGAEgASgJEg8KB3ZlcnNpb24YAiABKAkirwEKDEZyYW1lVmVyc2lvbhIPCgd2ZXJzaW9uGAEgASgJEhEKCWNoYW5nZWxvZxgCIAEoCRIOCgZkaWdlc3QYAyABKAkSEgoKc2l6ZV9ieXRlcxgEIAEoAxIUCgxwdWJsaXNoZWRfYnkYBSABKAkSMAoMcHVibGlzaGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjb250ZW50GAcgASgMIjMKC1Blcm1pc3Npb25zEhAKCGNhbl9lZGl0GAEgASgIEhIKCmNhbl9kZWxldGUYAiABKAgiywEKDEZyYW1lU3VtbWFyeRIQCghvcmdfc2x1ZxgBIAEoCRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEhEKCW93bmVyX3N1YhgEIAEoCRIWCg5sYXRlc3RfdmVyc2lvbhgFIAEoCRIuCgp1cGRhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIrCgtwZXJtaXNzaW9ucxgHIAEoCzIWLmZyYW1lcy52MS5QZXJtaXNzaW9ucyKBAQoTRnJhbWVWZXJzaW9uU3VtbWFyeRIPCgd2ZXJzaW9uGAEgASgJEhEKCWNoYW5nZWxvZxgCIAEoCRIUCgxwdWJsaXNoZWRfYnkYAyABKAkSMAoMcHVibGlzaGVkX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEKfAQoNY29tLmZyYW1lcy52MUIKRnJhbWVQcm90b1ABWj1naXRodWIuY29tL25lYmFyaS1kZXYvbmViYXJpLWZyYW1lcy9nZW4vZ28vZnJhbWVzL3YxO2ZyYW1lc3YxogIDRlhYqgIJRnJhbWVzLlYxygIJRnJhbWVzXFYx4gIVRnJhbWVzXFYxXEdQQk1ldGFkYXRh6gIKRnJhbWVzOjpWMWIGcHJvdG8z", [file_google_protobuf_timestamp]); + fileDesc("ChVmcmFtZXMvdjEvZnJhbWUucHJvdG8SCWZyYW1lcy52MSJlCgNPcmcSCgoCaWQYASABKAkSDAoEc2x1ZxgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSLgoKY3JlYXRlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAieQoKTWVtYmVyc2hpcBIOCgZvcmdfaWQYASABKAkSEAoIdXNlcl9zdWIYAiABKAkSDAoEcm9sZRgDIAEoCRIsCghhZGRlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDQoFZW1haWwYBSABKAki0QEKBUZyYW1lEgoKAmlkGAEgASgJEg4KBm9yZ19pZBgCIAEoCRIMCgRuYW1lGAMgASgJEhMKC2Rlc2NyaXB0aW9uGAQgASgJEhEKCW93bmVyX3N1YhgFIAEoCRIWCg5sYXRlc3RfdmVyc2lvbhgGIAEoCRIuCgpjcmVhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIpCglQYXJlbnRSZWYSCwoDcmVmGAEgASgJEg8KB3ZlcnNpb24YAiABKAkirwEKDEZyYW1lVmVyc2lvbhIPCgd2ZXJzaW9uGAEgASgJEhEKCWNoYW5nZWxvZxgCIAEoCRIOCgZkaWdlc3QYAyABKAkSEgoKc2l6ZV9ieXRlcxgEIAEoAxIUCgxwdWJsaXNoZWRfYnkYBSABKAkSMAoMcHVibGlzaGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjb250ZW50GAcgASgMIjMKC1Blcm1pc3Npb25zEhAKCGNhbl9lZGl0GAEgASgIEhIKCmNhbl9kZWxldGUYAiABKAgiywEKDEZyYW1lU3VtbWFyeRIQCghvcmdfc2x1ZxgBIAEoCRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEhEKCW93bmVyX3N1YhgEIAEoCRIWCg5sYXRlc3RfdmVyc2lvbhgFIAEoCRIuCgp1cGRhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIrCgtwZXJtaXNzaW9ucxgHIAEoCzIWLmZyYW1lcy52MS5QZXJtaXNzaW9ucyKBAQoTRnJhbWVWZXJzaW9uU3VtbWFyeRIPCgd2ZXJzaW9uGAEgASgJEhEKCWNoYW5nZWxvZxgCIAEoCRIUCgxwdWJsaXNoZWRfYnkYAyABKAkSMAoMcHVibGlzaGVkX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJACglGaWVsZFJ1bGUSJQoFbGV2ZWwYASABKA4yFi5mcmFtZXMudjEuUmVxdWlyZW1lbnQSDAoEbm90ZRgCIAEoCSJXChRGcmFtZVRlbXBsYXRlU3VtbWFyeRIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRIPCgdidWlsdGluGAQgASgIIukBCg1GcmFtZVRlbXBsYXRlEgoKAmlkGAEgASgJEg0KBXRpdGxlGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEg8KB2J1aWx0aW4YBCABKAgSDwoHcHJlZmlsbBgFIAEoDBI9CgtmaWVsZF9ydWxlcxgGIAMoCzIoLmZyYW1lcy52MS5GcmFtZVRlbXBsYXRlLkZpZWxkUnVsZXNFbnRyeRpHCg9GaWVsZFJ1bGVzRW50cnkSCwoDa2V5GAEgASgJEiMKBXZhbHVlGAIgASgLMhQuZnJhbWVzLnYxLkZpZWxkUnVsZToCOAEqewoLUmVxdWlyZW1lbnQSGwoXUkVRVUlSRU1FTlRfVU5TUEVDSUZJRUQQABIYChRSRVFVSVJFTUVOVF9PUFRJT05BTBABEhsKF1JFUVVJUkVNRU5UX1JFQ09NTUVOREVEEAISGAoUUkVRVUlSRU1FTlRfUkVRVUlSRUQQA0KfAQoNY29tLmZyYW1lcy52MUIKRnJhbWVQcm90b1ABWj1naXRodWIuY29tL25lYmFyaS1kZXYvbmViYXJpLWZyYW1lcy9nZW4vZ28vZnJhbWVzL3YxO2ZyYW1lc3YxogIDRlhYqgIJRnJhbWVzLlYxygIJRnJhbWVzXFYx4gIVRnJhbWVzXFYxXEdQQk1ldGFkYXRh6gIKRnJhbWVzOjpWMWIGcHJvdG8z", [file_google_protobuf_timestamp]); /** * @generated from message frames.v1.Org @@ -315,3 +315,154 @@ export type FrameVersionSummary = Message<"frames.v1.FrameVersionSummary"> & { export const FrameVersionSummarySchema: GenMessage = /*@__PURE__*/ messageDesc(file_frames_v1_frame, 7); +/** + * FieldRule is a template's expectation for one slot. `note` is the template + * author's own guidance on what belongs there: the seeded authoring form shows + * it in place of the generic per-slot hint, and an MCP client asks it as a + * question. A message rather than a bare Requirement because widening a map's + * value type later would be a breaking wire change. + * + * @generated from message frames.v1.FieldRule + */ +export type FieldRule = Message<"frames.v1.FieldRule"> & { + /** + * @generated from field: frames.v1.Requirement level = 1; + */ + level: Requirement; + + /** + * @generated from field: string note = 2; + */ + note: string; +}; + +/** + * Describes the message frames.v1.FieldRule. + * Use `create(FieldRuleSchema)` to create a new message. + */ +export const FieldRuleSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame, 8); + +/** + * FrameTemplateSummary is what a picker needs: no prefill, so listing stays + * cheap. `builtin` is computed server-side so no client has to parse an id. + * + * @generated from message frames.v1.FrameTemplateSummary + */ +export type FrameTemplateSummary = Message<"frames.v1.FrameTemplateSummary"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string title = 2; + */ + title: string; + + /** + * @generated from field: string description = 3; + */ + description: string; + + /** + * @generated from field: bool builtin = 4; + */ + builtin: boolean; +}; + +/** + * Describes the message frames.v1.FrameTemplateSummary. + * Use `create(FrameTemplateSummarySchema)` to create a new message. + */ +export const FrameTemplateSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame, 9); + +/** + * @generated from message frames.v1.FrameTemplate + */ +export type FrameTemplate = Message<"frames.v1.FrameTemplate"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string title = 2; + */ + title: string; + + /** + * @generated from field: string description = 3; + */ + description: string; + + /** + * @generated from field: bool builtin = 4; + */ + builtin: boolean; + + /** + * Canonical YAML carrying only `slots` and `extends`. Bytes rather than a + * structured message on purpose: a message mirroring the slot schema would be + * a second place to update whenever frames.SlotTable changes, which is what + * the reflective guards in the MCP tests exist to catch. + * + * @generated from field: bytes prefill = 5; + */ + prefill: Uint8Array; + + /** + * Slot key (terminology, rules, skills, prompts, tool_specs, goals, style, + * norms, architecture, business_process) -> rule. Keyed by string, so adding + * a slot does not change this shape either. + * + * @generated from field: map field_rules = 6; + */ + fieldRules: { [key: string]: FieldRule }; +}; + +/** + * Describes the message frames.v1.FrameTemplate. + * Use `create(FrameTemplateSchema)` to create a new message. + */ +export const FrameTemplateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame, 10); + +/** + * Requirement is how strongly a template wants a slot filled. Stricter than the + * Frame schema, which requires no slot at all: an org uses it to hold its own + * Frames to a house standard the spec does not impose. + * + * @generated from enum frames.v1.Requirement + */ +export enum Requirement { + /** + * treated as optional + * + * @generated from enum value: REQUIREMENT_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: REQUIREMENT_OPTIONAL = 1; + */ + OPTIONAL = 1, + + /** + * @generated from enum value: REQUIREMENT_RECOMMENDED = 2; + */ + RECOMMENDED = 2, + + /** + * @generated from enum value: REQUIREMENT_REQUIRED = 3; + */ + REQUIRED = 3, +} + +/** + * Describes the enum frames.v1.Requirement. + */ +export const RequirementSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_frames_v1_frame, 0); + diff --git a/gen/ts/frames/v1/frame_service_pb.ts b/gen/ts/frames/v1/frame_service_pb.ts index 6431d93..884003c 100644 --- a/gen/ts/frames/v1/frame_service_pb.ts +++ b/gen/ts/frames/v1/frame_service_pb.ts @@ -4,7 +4,7 @@ import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; -import type { Frame, FrameSummary, FrameVersion, FrameVersionSummary, Membership, Org, ParentRef, Permissions } from "./frame_pb"; +import type { FieldRule, Frame, FrameSummary, FrameTemplate, FrameTemplateSummary, FrameVersion, FrameVersionSummary, Membership, Org, ParentRef, Permissions } from "./frame_pb"; import { file_frames_v1_frame } from "./frame_pb"; import type { Message } from "@bufbuild/protobuf"; @@ -12,7 +12,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file frames/v1/frame_service.proto. */ export const file_frames_v1_frame_service: GenFile = /*@__PURE__*/ - fileDesc("Ch1mcmFtZXMvdjEvZnJhbWVfc2VydmljZS5wcm90bxIJZnJhbWVzLnYxIjkKE1B1Ymxpc2hGcmFtZVJlcXVlc3QSDwoHY29udGVudBgBIAEoDBIRCgljaGFuZ2Vsb2cYAiABKAkiYQoUUHVibGlzaEZyYW1lUmVzcG9uc2USHwoFZnJhbWUYASABKAsyEC5mcmFtZXMudjEuRnJhbWUSKAoHdmVyc2lvbhgCIAEoCzIXLmZyYW1lcy52MS5GcmFtZVZlcnNpb24iEwoRTGlzdEZyYW1lc1JlcXVlc3QiUQoSTGlzdEZyYW1lc1Jlc3BvbnNlEicKBmZyYW1lcxgBIAMoCzIXLmZyYW1lcy52MS5GcmFtZVN1bW1hcnkSEgoKY2FuX2NyZWF0ZRgCIAEoCCJCCg9HZXRGcmFtZVJlcXVlc3QSEAoIb3JnX3NsdWcYASABKAkSDAoEbmFtZRgCIAEoCRIPCgd2ZXJzaW9uGAMgASgJIsMBChBHZXRGcmFtZVJlc3BvbnNlEh8KBWZyYW1lGAEgASgLMhAuZnJhbWVzLnYxLkZyYW1lEigKB3ZlcnNpb24YAiABKAsyFy5mcmFtZXMudjEuRnJhbWVWZXJzaW9uEiUKB2V4dGVuZHMYAyADKAsyFC5mcmFtZXMudjEuUGFyZW50UmVmEhAKCGV4Y2x1ZGVzGAQgAygJEisKC3Blcm1pc3Npb25zGAUgASgLMhYuZnJhbWVzLnYxLlBlcm1pc3Npb25zIkYKE1Jlc29sdmVGcmFtZVJlcXVlc3QSEAoIb3JnX3NsdWcYASABKAkSDAoEbmFtZRgCIAEoCRIPCgd2ZXJzaW9uGAMgASgJIjAKFFJlc29sdmVGcmFtZVJlc3BvbnNlEhgKEHJlc29sdmVkX2NvbnRlbnQYASABKAwiDgoMR2V0TWVSZXF1ZXN0Im4KDUdldE1lUmVzcG9uc2USDwoHc3ViamVjdBgBIAEoCRINCgVlbWFpbBgCIAEoCRIbCgNvcmcYAyABKAsyDi5mcmFtZXMudjEuT3JnEgwKBHJvbGUYBCABKAkSEgoKY2FuX2NyZWF0ZRgFIAEoCCI6ChhMaXN0RnJhbWVWZXJzaW9uc1JlcXVlc3QSEAoIb3JnX3NsdWcYASABKAkSDAoEbmFtZRgCIAEoCSJNChlMaXN0RnJhbWVWZXJzaW9uc1Jlc3BvbnNlEjAKCHZlcnNpb25zGAEgAygLMh4uZnJhbWVzLnYxLkZyYW1lVmVyc2lvblN1bW1hcnkiMAoORmllbGRWaW9sYXRpb24SDQoFZmllbGQYASABKAkSDwoHbWVzc2FnZRgCIAEoCSJACg9GaWVsZFZpb2xhdGlvbnMSLQoKdmlvbGF0aW9ucxgBIAMoCzIZLmZyYW1lcy52MS5GaWVsZFZpb2xhdGlvbiJDChNDb252ZXJ0RnJhbWVSZXF1ZXN0Eg4KBHlhbWwYASABKAxIABISCghtYXJrZG93bhgCIAEoDEgAQggKBnNvdXJjZSI2ChRDb252ZXJ0RnJhbWVSZXNwb25zZRIMCgR5YW1sGAEgASgMEhAKCG1hcmtkb3duGAIgASgMIkMKEkRlbGV0ZUZyYW1lUmVxdWVzdBIQCghvcmdfc2x1ZxgBIAEoCRIMCgRuYW1lGAIgASgJEg0KBWZvcmNlGAMgASgIIhUKE0RlbGV0ZUZyYW1lUmVzcG9uc2UiKAoNRGVsZXRlQmxvY2tlZBIXCg9ibG9ja2luZ19mcmFtZXMYASADKAkiFwoVTGlzdE9yZ01lbWJlcnNSZXF1ZXN0IkAKFkxpc3RPcmdNZW1iZXJzUmVzcG9uc2USJgoHbWVtYmVycxgBIAMoCzIVLmZyYW1lcy52MS5NZW1iZXJzaGlwIjIKE0FkZE9yZ01lbWJlclJlcXVlc3QSDQoFZW1haWwYASABKAkSDAoEcm9sZRgCIAEoCSI9ChRBZGRPcmdNZW1iZXJSZXNwb25zZRIlCgZtZW1iZXIYASABKAsyFS5mcmFtZXMudjEuTWVtYmVyc2hpcCJFChRTZXRNZW1iZXJSb2xlUmVxdWVzdBIQCgh1c2VyX3N1YhgBIAEoCRINCgVlbWFpbBgCIAEoCRIMCgRyb2xlGAMgASgJIj4KFVNldE1lbWJlclJvbGVSZXNwb25zZRIlCgZtZW1iZXIYASABKAsyFS5mcmFtZXMudjEuTWVtYmVyc2hpcCI5ChZSZW1vdmVPcmdNZW1iZXJSZXF1ZXN0EhAKCHVzZXJfc3ViGAEgASgJEg0KBWVtYWlsGAIgASgJIhkKF1JlbW92ZU9yZ01lbWJlclJlc3BvbnNlMtEHCgxGcmFtZVNlcnZpY2USTwoMUHVibGlzaEZyYW1lEh4uZnJhbWVzLnYxLlB1Ymxpc2hGcmFtZVJlcXVlc3QaHy5mcmFtZXMudjEuUHVibGlzaEZyYW1lUmVzcG9uc2USSQoKTGlzdEZyYW1lcxIcLmZyYW1lcy52MS5MaXN0RnJhbWVzUmVxdWVzdBodLmZyYW1lcy52MS5MaXN0RnJhbWVzUmVzcG9uc2USQwoIR2V0RnJhbWUSGi5mcmFtZXMudjEuR2V0RnJhbWVSZXF1ZXN0GhsuZnJhbWVzLnYxLkdldEZyYW1lUmVzcG9uc2USTwoMUmVzb2x2ZUZyYW1lEh4uZnJhbWVzLnYxLlJlc29sdmVGcmFtZVJlcXVlc3QaHy5mcmFtZXMudjEuUmVzb2x2ZUZyYW1lUmVzcG9uc2USOgoFR2V0TWUSFy5mcmFtZXMudjEuR2V0TWVSZXF1ZXN0GhguZnJhbWVzLnYxLkdldE1lUmVzcG9uc2USXgoRTGlzdEZyYW1lVmVyc2lvbnMSIy5mcmFtZXMudjEuTGlzdEZyYW1lVmVyc2lvbnNSZXF1ZXN0GiQuZnJhbWVzLnYxLkxpc3RGcmFtZVZlcnNpb25zUmVzcG9uc2USTAoLRGVsZXRlRnJhbWUSHS5mcmFtZXMudjEuRGVsZXRlRnJhbWVSZXF1ZXN0Gh4uZnJhbWVzLnYxLkRlbGV0ZUZyYW1lUmVzcG9uc2USTwoMQ29udmVydEZyYW1lEh4uZnJhbWVzLnYxLkNvbnZlcnRGcmFtZVJlcXVlc3QaHy5mcmFtZXMudjEuQ29udmVydEZyYW1lUmVzcG9uc2USVQoOTGlzdE9yZ01lbWJlcnMSIC5mcmFtZXMudjEuTGlzdE9yZ01lbWJlcnNSZXF1ZXN0GiEuZnJhbWVzLnYxLkxpc3RPcmdNZW1iZXJzUmVzcG9uc2USTwoMQWRkT3JnTWVtYmVyEh4uZnJhbWVzLnYxLkFkZE9yZ01lbWJlclJlcXVlc3QaHy5mcmFtZXMudjEuQWRkT3JnTWVtYmVyUmVzcG9uc2USUgoNU2V0TWVtYmVyUm9sZRIfLmZyYW1lcy52MS5TZXRNZW1iZXJSb2xlUmVxdWVzdBogLmZyYW1lcy52MS5TZXRNZW1iZXJSb2xlUmVzcG9uc2USWAoPUmVtb3ZlT3JnTWVtYmVyEiEuZnJhbWVzLnYxLlJlbW92ZU9yZ01lbWJlclJlcXVlc3QaIi5mcmFtZXMudjEuUmVtb3ZlT3JnTWVtYmVyUmVzcG9uc2VCpgEKDWNvbS5mcmFtZXMudjFCEUZyYW1lU2VydmljZVByb3RvUAFaPWdpdGh1Yi5jb20vbmViYXJpLWRldi9uZWJhcmktZnJhbWVzL2dlbi9nby9mcmFtZXMvdjE7ZnJhbWVzdjGiAgNGWFiqAglGcmFtZXMuVjHKAglGcmFtZXNcVjHiAhVGcmFtZXNcVjFcR1BCTWV0YWRhdGHqAgpGcmFtZXM6OlYxYgZwcm90bzM", [file_frames_v1_frame]); + fileDesc("Ch1mcmFtZXMvdjEvZnJhbWVfc2VydmljZS5wcm90bxIJZnJhbWVzLnYxIk4KE1B1Ymxpc2hGcmFtZVJlcXVlc3QSDwoHY29udGVudBgBIAEoDBIRCgljaGFuZ2Vsb2cYAiABKAkSEwoLdGVtcGxhdGVfaWQYAyABKAkiYQoUUHVibGlzaEZyYW1lUmVzcG9uc2USHwoFZnJhbWUYASABKAsyEC5mcmFtZXMudjEuRnJhbWUSKAoHdmVyc2lvbhgCIAEoCzIXLmZyYW1lcy52MS5GcmFtZVZlcnNpb24iEwoRTGlzdEZyYW1lc1JlcXVlc3QiUQoSTGlzdEZyYW1lc1Jlc3BvbnNlEicKBmZyYW1lcxgBIAMoCzIXLmZyYW1lcy52MS5GcmFtZVN1bW1hcnkSEgoKY2FuX2NyZWF0ZRgCIAEoCCJCCg9HZXRGcmFtZVJlcXVlc3QSEAoIb3JnX3NsdWcYASABKAkSDAoEbmFtZRgCIAEoCRIPCgd2ZXJzaW9uGAMgASgJIsMBChBHZXRGcmFtZVJlc3BvbnNlEh8KBWZyYW1lGAEgASgLMhAuZnJhbWVzLnYxLkZyYW1lEigKB3ZlcnNpb24YAiABKAsyFy5mcmFtZXMudjEuRnJhbWVWZXJzaW9uEiUKB2V4dGVuZHMYAyADKAsyFC5mcmFtZXMudjEuUGFyZW50UmVmEhAKCGV4Y2x1ZGVzGAQgAygJEisKC3Blcm1pc3Npb25zGAUgASgLMhYuZnJhbWVzLnYxLlBlcm1pc3Npb25zIkYKE1Jlc29sdmVGcmFtZVJlcXVlc3QSEAoIb3JnX3NsdWcYASABKAkSDAoEbmFtZRgCIAEoCRIPCgd2ZXJzaW9uGAMgASgJIjAKFFJlc29sdmVGcmFtZVJlc3BvbnNlEhgKEHJlc29sdmVkX2NvbnRlbnQYASABKAwiDgoMR2V0TWVSZXF1ZXN0Im4KDUdldE1lUmVzcG9uc2USDwoHc3ViamVjdBgBIAEoCRINCgVlbWFpbBgCIAEoCRIbCgNvcmcYAyABKAsyDi5mcmFtZXMudjEuT3JnEgwKBHJvbGUYBCABKAkSEgoKY2FuX2NyZWF0ZRgFIAEoCCI6ChhMaXN0RnJhbWVWZXJzaW9uc1JlcXVlc3QSEAoIb3JnX3NsdWcYASABKAkSDAoEbmFtZRgCIAEoCSJNChlMaXN0RnJhbWVWZXJzaW9uc1Jlc3BvbnNlEjAKCHZlcnNpb25zGAEgAygLMh4uZnJhbWVzLnYxLkZyYW1lVmVyc2lvblN1bW1hcnkiMAoORmllbGRWaW9sYXRpb24SDQoFZmllbGQYASABKAkSDwoHbWVzc2FnZRgCIAEoCSJACg9GaWVsZFZpb2xhdGlvbnMSLQoKdmlvbGF0aW9ucxgBIAMoCzIZLmZyYW1lcy52MS5GaWVsZFZpb2xhdGlvbiJDChNDb252ZXJ0RnJhbWVSZXF1ZXN0Eg4KBHlhbWwYASABKAxIABISCghtYXJrZG93bhgCIAEoDEgAQggKBnNvdXJjZSI2ChRDb252ZXJ0RnJhbWVSZXNwb25zZRIMCgR5YW1sGAEgASgMEhAKCG1hcmtkb3duGAIgASgMIkMKEkRlbGV0ZUZyYW1lUmVxdWVzdBIQCghvcmdfc2x1ZxgBIAEoCRIMCgRuYW1lGAIgASgJEg0KBWZvcmNlGAMgASgIIhUKE0RlbGV0ZUZyYW1lUmVzcG9uc2UiKAoNRGVsZXRlQmxvY2tlZBIXCg9ibG9ja2luZ19mcmFtZXMYASADKAkiFwoVTGlzdE9yZ01lbWJlcnNSZXF1ZXN0IkAKFkxpc3RPcmdNZW1iZXJzUmVzcG9uc2USJgoHbWVtYmVycxgBIAMoCzIVLmZyYW1lcy52MS5NZW1iZXJzaGlwIjIKE0FkZE9yZ01lbWJlclJlcXVlc3QSDQoFZW1haWwYASABKAkSDAoEcm9sZRgCIAEoCSI9ChRBZGRPcmdNZW1iZXJSZXNwb25zZRIlCgZtZW1iZXIYASABKAsyFS5mcmFtZXMudjEuTWVtYmVyc2hpcCJFChRTZXRNZW1iZXJSb2xlUmVxdWVzdBIQCgh1c2VyX3N1YhgBIAEoCRINCgVlbWFpbBgCIAEoCRIMCgRyb2xlGAMgASgJIj4KFVNldE1lbWJlclJvbGVSZXNwb25zZRIlCgZtZW1iZXIYASABKAsyFS5mcmFtZXMudjEuTWVtYmVyc2hpcCI5ChZSZW1vdmVPcmdNZW1iZXJSZXF1ZXN0EhAKCHVzZXJfc3ViGAEgASgJEg0KBWVtYWlsGAIgASgJIhkKF1JlbW92ZU9yZ01lbWJlclJlc3BvbnNlIhsKGUxpc3RGcmFtZVRlbXBsYXRlc1JlcXVlc3QiZAoaTGlzdEZyYW1lVGVtcGxhdGVzUmVzcG9uc2USMgoJdGVtcGxhdGVzGAEgAygLMh8uZnJhbWVzLnYxLkZyYW1lVGVtcGxhdGVTdW1tYXJ5EhIKCmNhbl9tYW5hZ2UYAiABKAgiJQoXR2V0RnJhbWVUZW1wbGF0ZVJlcXVlc3QSCgoCaWQYASABKAkiRgoYR2V0RnJhbWVUZW1wbGF0ZVJlc3BvbnNlEioKCHRlbXBsYXRlGAEgASgLMhguZnJhbWVzLnYxLkZyYW1lVGVtcGxhdGUi5gEKGkNyZWF0ZUZyYW1lVGVtcGxhdGVSZXF1ZXN0Eg0KBXRpdGxlGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEg8KB3ByZWZpbGwYAyABKAwSSgoLZmllbGRfcnVsZXMYBCADKAsyNS5mcmFtZXMudjEuQ3JlYXRlRnJhbWVUZW1wbGF0ZVJlcXVlc3QuRmllbGRSdWxlc0VudHJ5GkcKD0ZpZWxkUnVsZXNFbnRyeRILCgNrZXkYASABKAkSIwoFdmFsdWUYAiABKAsyFC5mcmFtZXMudjEuRmllbGRSdWxlOgI4ASJJChtDcmVhdGVGcmFtZVRlbXBsYXRlUmVzcG9uc2USKgoIdGVtcGxhdGUYASABKAsyGC5mcmFtZXMudjEuRnJhbWVUZW1wbGF0ZSLyAQoaVXBkYXRlRnJhbWVUZW1wbGF0ZVJlcXVlc3QSCgoCaWQYASABKAkSDQoFdGl0bGUYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSDwoHcHJlZmlsbBgEIAEoDBJKCgtmaWVsZF9ydWxlcxgFIAMoCzI1LmZyYW1lcy52MS5VcGRhdGVGcmFtZVRlbXBsYXRlUmVxdWVzdC5GaWVsZFJ1bGVzRW50cnkaRwoPRmllbGRSdWxlc0VudHJ5EgsKA2tleRgBIAEoCRIjCgV2YWx1ZRgCIAEoCzIULmZyYW1lcy52MS5GaWVsZFJ1bGU6AjgBIkkKG1VwZGF0ZUZyYW1lVGVtcGxhdGVSZXNwb25zZRIqCgh0ZW1wbGF0ZRgBIAEoCzIYLmZyYW1lcy52MS5GcmFtZVRlbXBsYXRlIigKGkRlbGV0ZUZyYW1lVGVtcGxhdGVSZXF1ZXN0EgoKAmlkGAEgASgJIh0KG0RlbGV0ZUZyYW1lVGVtcGxhdGVSZXNwb25zZTLDCwoMRnJhbWVTZXJ2aWNlEk8KDFB1Ymxpc2hGcmFtZRIeLmZyYW1lcy52MS5QdWJsaXNoRnJhbWVSZXF1ZXN0Gh8uZnJhbWVzLnYxLlB1Ymxpc2hGcmFtZVJlc3BvbnNlEkkKCkxpc3RGcmFtZXMSHC5mcmFtZXMudjEuTGlzdEZyYW1lc1JlcXVlc3QaHS5mcmFtZXMudjEuTGlzdEZyYW1lc1Jlc3BvbnNlEkMKCEdldEZyYW1lEhouZnJhbWVzLnYxLkdldEZyYW1lUmVxdWVzdBobLmZyYW1lcy52MS5HZXRGcmFtZVJlc3BvbnNlEk8KDFJlc29sdmVGcmFtZRIeLmZyYW1lcy52MS5SZXNvbHZlRnJhbWVSZXF1ZXN0Gh8uZnJhbWVzLnYxLlJlc29sdmVGcmFtZVJlc3BvbnNlEjoKBUdldE1lEhcuZnJhbWVzLnYxLkdldE1lUmVxdWVzdBoYLmZyYW1lcy52MS5HZXRNZVJlc3BvbnNlEl4KEUxpc3RGcmFtZVZlcnNpb25zEiMuZnJhbWVzLnYxLkxpc3RGcmFtZVZlcnNpb25zUmVxdWVzdBokLmZyYW1lcy52MS5MaXN0RnJhbWVWZXJzaW9uc1Jlc3BvbnNlEkwKC0RlbGV0ZUZyYW1lEh0uZnJhbWVzLnYxLkRlbGV0ZUZyYW1lUmVxdWVzdBoeLmZyYW1lcy52MS5EZWxldGVGcmFtZVJlc3BvbnNlEk8KDENvbnZlcnRGcmFtZRIeLmZyYW1lcy52MS5Db252ZXJ0RnJhbWVSZXF1ZXN0Gh8uZnJhbWVzLnYxLkNvbnZlcnRGcmFtZVJlc3BvbnNlEmEKEkxpc3RGcmFtZVRlbXBsYXRlcxIkLmZyYW1lcy52MS5MaXN0RnJhbWVUZW1wbGF0ZXNSZXF1ZXN0GiUuZnJhbWVzLnYxLkxpc3RGcmFtZVRlbXBsYXRlc1Jlc3BvbnNlElsKEEdldEZyYW1lVGVtcGxhdGUSIi5mcmFtZXMudjEuR2V0RnJhbWVUZW1wbGF0ZVJlcXVlc3QaIy5mcmFtZXMudjEuR2V0RnJhbWVUZW1wbGF0ZVJlc3BvbnNlEmQKE0NyZWF0ZUZyYW1lVGVtcGxhdGUSJS5mcmFtZXMudjEuQ3JlYXRlRnJhbWVUZW1wbGF0ZVJlcXVlc3QaJi5mcmFtZXMudjEuQ3JlYXRlRnJhbWVUZW1wbGF0ZVJlc3BvbnNlEmQKE1VwZGF0ZUZyYW1lVGVtcGxhdGUSJS5mcmFtZXMudjEuVXBkYXRlRnJhbWVUZW1wbGF0ZVJlcXVlc3QaJi5mcmFtZXMudjEuVXBkYXRlRnJhbWVUZW1wbGF0ZVJlc3BvbnNlEmQKE0RlbGV0ZUZyYW1lVGVtcGxhdGUSJS5mcmFtZXMudjEuRGVsZXRlRnJhbWVUZW1wbGF0ZVJlcXVlc3QaJi5mcmFtZXMudjEuRGVsZXRlRnJhbWVUZW1wbGF0ZVJlc3BvbnNlElUKDkxpc3RPcmdNZW1iZXJzEiAuZnJhbWVzLnYxLkxpc3RPcmdNZW1iZXJzUmVxdWVzdBohLmZyYW1lcy52MS5MaXN0T3JnTWVtYmVyc1Jlc3BvbnNlEk8KDEFkZE9yZ01lbWJlchIeLmZyYW1lcy52MS5BZGRPcmdNZW1iZXJSZXF1ZXN0Gh8uZnJhbWVzLnYxLkFkZE9yZ01lbWJlclJlc3BvbnNlElIKDVNldE1lbWJlclJvbGUSHy5mcmFtZXMudjEuU2V0TWVtYmVyUm9sZVJlcXVlc3QaIC5mcmFtZXMudjEuU2V0TWVtYmVyUm9sZVJlc3BvbnNlElgKD1JlbW92ZU9yZ01lbWJlchIhLmZyYW1lcy52MS5SZW1vdmVPcmdNZW1iZXJSZXF1ZXN0GiIuZnJhbWVzLnYxLlJlbW92ZU9yZ01lbWJlclJlc3BvbnNlQqYBCg1jb20uZnJhbWVzLnYxQhFGcmFtZVNlcnZpY2VQcm90b1ABWj1naXRodWIuY29tL25lYmFyaS1kZXYvbmViYXJpLWZyYW1lcy9nZW4vZ28vZnJhbWVzL3YxO2ZyYW1lc3YxogIDRlhYqgIJRnJhbWVzLlYxygIJRnJhbWVzXFYx4gIVRnJhbWVzXFYxXEdQQk1ldGFkYXRh6gIKRnJhbWVzOjpWMWIGcHJvdG8z", [file_frames_v1_frame]); /** * @generated from message frames.v1.PublishFrameRequest @@ -29,6 +29,16 @@ export type PublishFrameRequest = Message<"frames.v1.PublishFrameRequest"> & { * @generated from field: string changelog = 2; */ changelog: string; + + /** + * Optional. Names the template this Frame is being created from, so its + * required slots are checked. Used for validation and then discarded: + * nothing is recorded on the Frame, and the check applies on create only. + * Empty means no template and no checks. + * + * @generated from field: string template_id = 3; + */ + templateId: string; }; /** @@ -621,6 +631,211 @@ export type RemoveOrgMemberResponse = Message<"frames.v1.RemoveOrgMemberResponse export const RemoveOrgMemberResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_frames_v1_frame_service, 26); +/** + * @generated from message frames.v1.ListFrameTemplatesRequest + */ +export type ListFrameTemplatesRequest = Message<"frames.v1.ListFrameTemplatesRequest"> & { +}; + +/** + * Describes the message frames.v1.ListFrameTemplatesRequest. + * Use `create(ListFrameTemplatesRequestSchema)` to create a new message. + */ +export const ListFrameTemplatesRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame_service, 27); + +/** + * @generated from message frames.v1.ListFrameTemplatesResponse + */ +export type ListFrameTemplatesResponse = Message<"frames.v1.ListFrameTemplatesResponse"> & { + /** + * @generated from field: repeated frames.v1.FrameTemplateSummary templates = 1; + */ + templates: FrameTemplateSummary[]; + + /** + * Whether the caller may create, edit, and delete org templates, so the SPA + * does not have to infer admin-ness itself. Mirrors ListFramesResponse.can_create. + * + * @generated from field: bool can_manage = 2; + */ + canManage: boolean; +}; + +/** + * Describes the message frames.v1.ListFrameTemplatesResponse. + * Use `create(ListFrameTemplatesResponseSchema)` to create a new message. + */ +export const ListFrameTemplatesResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame_service, 28); + +/** + * @generated from message frames.v1.GetFrameTemplateRequest + */ +export type GetFrameTemplateRequest = Message<"frames.v1.GetFrameTemplateRequest"> & { + /** + * @generated from field: string id = 1; + */ + id: string; +}; + +/** + * Describes the message frames.v1.GetFrameTemplateRequest. + * Use `create(GetFrameTemplateRequestSchema)` to create a new message. + */ +export const GetFrameTemplateRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame_service, 29); + +/** + * @generated from message frames.v1.GetFrameTemplateResponse + */ +export type GetFrameTemplateResponse = Message<"frames.v1.GetFrameTemplateResponse"> & { + /** + * @generated from field: frames.v1.FrameTemplate template = 1; + */ + template?: FrameTemplate | undefined; +}; + +/** + * Describes the message frames.v1.GetFrameTemplateResponse. + * Use `create(GetFrameTemplateResponseSchema)` to create a new message. + */ +export const GetFrameTemplateResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame_service, 30); + +/** + * @generated from message frames.v1.CreateFrameTemplateRequest + */ +export type CreateFrameTemplateRequest = Message<"frames.v1.CreateFrameTemplateRequest"> & { + /** + * @generated from field: string title = 1; + */ + title: string; + + /** + * @generated from field: string description = 2; + */ + description: string; + + /** + * @generated from field: bytes prefill = 3; + */ + prefill: Uint8Array; + + /** + * @generated from field: map field_rules = 4; + */ + fieldRules: { [key: string]: FieldRule }; +}; + +/** + * Describes the message frames.v1.CreateFrameTemplateRequest. + * Use `create(CreateFrameTemplateRequestSchema)` to create a new message. + */ +export const CreateFrameTemplateRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame_service, 31); + +/** + * @generated from message frames.v1.CreateFrameTemplateResponse + */ +export type CreateFrameTemplateResponse = Message<"frames.v1.CreateFrameTemplateResponse"> & { + /** + * @generated from field: frames.v1.FrameTemplate template = 1; + */ + template?: FrameTemplate | undefined; +}; + +/** + * Describes the message frames.v1.CreateFrameTemplateResponse. + * Use `create(CreateFrameTemplateResponseSchema)` to create a new message. + */ +export const CreateFrameTemplateResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame_service, 32); + +/** + * @generated from message frames.v1.UpdateFrameTemplateRequest + */ +export type UpdateFrameTemplateRequest = Message<"frames.v1.UpdateFrameTemplateRequest"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string title = 2; + */ + title: string; + + /** + * @generated from field: string description = 3; + */ + description: string; + + /** + * @generated from field: bytes prefill = 4; + */ + prefill: Uint8Array; + + /** + * @generated from field: map field_rules = 5; + */ + fieldRules: { [key: string]: FieldRule }; +}; + +/** + * Describes the message frames.v1.UpdateFrameTemplateRequest. + * Use `create(UpdateFrameTemplateRequestSchema)` to create a new message. + */ +export const UpdateFrameTemplateRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame_service, 33); + +/** + * @generated from message frames.v1.UpdateFrameTemplateResponse + */ +export type UpdateFrameTemplateResponse = Message<"frames.v1.UpdateFrameTemplateResponse"> & { + /** + * @generated from field: frames.v1.FrameTemplate template = 1; + */ + template?: FrameTemplate | undefined; +}; + +/** + * Describes the message frames.v1.UpdateFrameTemplateResponse. + * Use `create(UpdateFrameTemplateResponseSchema)` to create a new message. + */ +export const UpdateFrameTemplateResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame_service, 34); + +/** + * @generated from message frames.v1.DeleteFrameTemplateRequest + */ +export type DeleteFrameTemplateRequest = Message<"frames.v1.DeleteFrameTemplateRequest"> & { + /** + * @generated from field: string id = 1; + */ + id: string; +}; + +/** + * Describes the message frames.v1.DeleteFrameTemplateRequest. + * Use `create(DeleteFrameTemplateRequestSchema)` to create a new message. + */ +export const DeleteFrameTemplateRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame_service, 35); + +/** + * @generated from message frames.v1.DeleteFrameTemplateResponse + */ +export type DeleteFrameTemplateResponse = Message<"frames.v1.DeleteFrameTemplateResponse"> & { +}; + +/** + * Describes the message frames.v1.DeleteFrameTemplateResponse. + * Use `create(DeleteFrameTemplateResponseSchema)` to create a new message. + */ +export const DeleteFrameTemplateResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_frames_v1_frame_service, 36); + /** * @generated from service frames.v1.FrameService */ @@ -706,6 +921,60 @@ export const FrameService: GenService<{ input: typeof ConvertFrameRequestSchema; output: typeof ConvertFrameResponseSchema; }, + /** + * Read - the templates the caller may start a Frame from: the built-ins plus + * their own org's. Any member may call it. + * + * @generated from rpc frames.v1.FrameService.ListFrameTemplates + */ + listFrameTemplates: { + methodKind: "unary"; + input: typeof ListFrameTemplatesRequestSchema; + output: typeof ListFrameTemplatesResponseSchema; + }, + /** + * Read - one template with its prefill and rules. 404 for a built-in that + * does not exist and for another org's template alike, so existence does not + * leak. + * + * @generated from rpc frames.v1.FrameService.GetFrameTemplate + */ + getFrameTemplate: { + methodKind: "unary"; + input: typeof GetFrameTemplateRequestSchema; + output: typeof GetFrameTemplateResponseSchema; + }, + /** + * Admin only - create an org template. + * + * @generated from rpc frames.v1.FrameService.CreateFrameTemplate + */ + createFrameTemplate: { + methodKind: "unary"; + input: typeof CreateFrameTemplateRequestSchema; + output: typeof CreateFrameTemplateResponseSchema; + }, + /** + * Admin only - replace an org template's content. Built-ins are immutable. + * + * @generated from rpc frames.v1.FrameService.UpdateFrameTemplate + */ + updateFrameTemplate: { + methodKind: "unary"; + input: typeof UpdateFrameTemplateRequestSchema; + output: typeof UpdateFrameTemplateResponseSchema; + }, + /** + * Admin only - delete an org template. Frames already created from it are + * unaffected: a template is copied once and never referenced again. + * + * @generated from rpc frames.v1.FrameService.DeleteFrameTemplate + */ + deleteFrameTemplate: { + methodKind: "unary"; + input: typeof DeleteFrameTemplateRequestSchema; + output: typeof DeleteFrameTemplateResponseSchema; + }, /** * Admin only - list the caller's org members. * diff --git a/proto/frames/v1/frame.proto b/proto/frames/v1/frame.proto index 6aeb4d5..b5faede 100644 --- a/proto/frames/v1/frame.proto +++ b/proto/frames/v1/frame.proto @@ -67,3 +67,48 @@ message FrameVersionSummary { string published_by = 3; google.protobuf.Timestamp published_at = 4; } + +// Requirement is how strongly a template wants a slot filled. Stricter than the +// Frame schema, which requires no slot at all: an org uses it to hold its own +// Frames to a house standard the spec does not impose. +enum Requirement { + REQUIREMENT_UNSPECIFIED = 0; // treated as optional + REQUIREMENT_OPTIONAL = 1; + REQUIREMENT_RECOMMENDED = 2; + REQUIREMENT_REQUIRED = 3; +} + +// FieldRule is a template's expectation for one slot. `note` is the template +// author's own guidance on what belongs there: the seeded authoring form shows +// it in place of the generic per-slot hint, and an MCP client asks it as a +// question. A message rather than a bare Requirement because widening a map's +// value type later would be a breaking wire change. +message FieldRule { + Requirement level = 1; + string note = 2; +} + +// FrameTemplateSummary is what a picker needs: no prefill, so listing stays +// cheap. `builtin` is computed server-side so no client has to parse an id. +message FrameTemplateSummary { + string id = 1; + string title = 2; + string description = 3; + bool builtin = 4; +} + +message FrameTemplate { + string id = 1; + string title = 2; + string description = 3; + bool builtin = 4; + // Canonical YAML carrying only `slots` and `extends`. Bytes rather than a + // structured message on purpose: a message mirroring the slot schema would be + // a second place to update whenever frames.SlotTable changes, which is what + // the reflective guards in the MCP tests exist to catch. + bytes prefill = 5; + // Slot key (terminology, rules, skills, prompts, tool_specs, goals, style, + // norms, architecture, business_process) -> rule. Keyed by string, so adding + // a slot does not change this shape either. + map field_rules = 6; +} diff --git a/proto/frames/v1/frame_service.proto b/proto/frames/v1/frame_service.proto index b4a9f3c..43f9e18 100644 --- a/proto/frames/v1/frame_service.proto +++ b/proto/frames/v1/frame_service.proto @@ -23,6 +23,20 @@ service FrameService { // Pure conversion between the canonical slot YAML and the spec-conformant // .frame.md form. Stateless and unauthenticated beyond org membership. rpc ConvertFrame(ConvertFrameRequest) returns (ConvertFrameResponse); + // Read - the templates the caller may start a Frame from: the built-ins plus + // their own org's. Any member may call it. + rpc ListFrameTemplates(ListFrameTemplatesRequest) returns (ListFrameTemplatesResponse); + // Read - one template with its prefill and rules. 404 for a built-in that + // does not exist and for another org's template alike, so existence does not + // leak. + rpc GetFrameTemplate(GetFrameTemplateRequest) returns (GetFrameTemplateResponse); + // Admin only - create an org template. + rpc CreateFrameTemplate(CreateFrameTemplateRequest) returns (CreateFrameTemplateResponse); + // Admin only - replace an org template's content. Built-ins are immutable. + rpc UpdateFrameTemplate(UpdateFrameTemplateRequest) returns (UpdateFrameTemplateResponse); + // Admin only - delete an org template. Frames already created from it are + // unaffected: a template is copied once and never referenced again. + rpc DeleteFrameTemplate(DeleteFrameTemplateRequest) returns (DeleteFrameTemplateResponse); // Admin only - list the caller's org members. rpc ListOrgMembers(ListOrgMembersRequest) returns (ListOrgMembersResponse); // Admin only - add a member to the caller's org by email (pending until login). @@ -36,6 +50,11 @@ service FrameService { message PublishFrameRequest { bytes content = 1; // full YAML; name/version/extends/excludes/slots parsed server-side string changelog = 2; + // Optional. Names the template this Frame is being created from, so its + // required slots are checked. Used for validation and then discarded: + // nothing is recorded on the Frame, and the check applies on create only. + // Empty means no template and no checks. + string template_id = 3; } message PublishFrameResponse { Frame frame = 1; @@ -157,3 +176,34 @@ message RemoveOrgMemberRequest { string email = 2; } message RemoveOrgMemberResponse {} + +message ListFrameTemplatesRequest {} +message ListFrameTemplatesResponse { + repeated FrameTemplateSummary templates = 1; + // Whether the caller may create, edit, and delete org templates, so the SPA + // does not have to infer admin-ness itself. Mirrors ListFramesResponse.can_create. + bool can_manage = 2; +} + +message GetFrameTemplateRequest { string id = 1; } +message GetFrameTemplateResponse { FrameTemplate template = 1; } + +message CreateFrameTemplateRequest { + string title = 1; + string description = 2; + bytes prefill = 3; + map field_rules = 4; +} +message CreateFrameTemplateResponse { FrameTemplate template = 1; } + +message UpdateFrameTemplateRequest { + string id = 1; + string title = 2; + string description = 3; + bytes prefill = 4; + map field_rules = 5; +} +message UpdateFrameTemplateResponse { FrameTemplate template = 1; } + +message DeleteFrameTemplateRequest { string id = 1; } +message DeleteFrameTemplateResponse {} From 06c5083dc4a1d05259ff5e67420a40600319a21f Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:46:31 +0200 Subject: [PATCH 08/43] feat(frames): serve Frame templates over ListFrameTemplates and GetFrameTemplate --- backend/internal/frames/templates.go | 50 ++++ backend/internal/frames/templates_service.go | 177 ++++++++++++ .../internal/frames/templates_service_test.go | 251 ++++++++++++++++++ 3 files changed, 478 insertions(+) create mode 100644 backend/internal/frames/templates_service.go create mode 100644 backend/internal/frames/templates_service_test.go diff --git a/backend/internal/frames/templates.go b/backend/internal/frames/templates.go index 9a06c8d..3fd6685 100644 --- a/backend/internal/frames/templates.go +++ b/backend/internal/frames/templates.go @@ -2,6 +2,7 @@ package frames import ( "bytes" + "encoding/json" "fmt" "strings" @@ -237,6 +238,55 @@ func MarshalPrefill(p Prefill) ([]byte, error) { return yaml.Marshal(prefillDoc{Extends: p.Extends, Slots: p.Slots}) } +// Field rules are persisted as JSON with the level written by name, never as the +// enum's integer: reordering the Requirement constants must not silently +// reinterpret every row already on disk. This is also why the wire enum has an +// explicit UNSPECIFIED = 0. + +type fieldRuleJSON struct { + Level string `json:"level"` + Note string `json:"note,omitempty"` +} + +// MarshalFieldRules encodes rules for storage in frame_templates.field_rules. +func MarshalFieldRules(rules map[string]FieldRule) ([]byte, error) { + out := make(map[string]fieldRuleJSON, len(rules)) + for key, rule := range rules { + out[key] = fieldRuleJSON{Level: rule.Level.String(), Note: rule.Note} + } + return json.Marshal(out) +} + +// ParseFieldRules decodes a stored field_rules blob. An unknown level is an +// error rather than a fall back to optional, for the same reason the YAML +// decoder refuses one: a requirement that quietly stops applying is worse than +// a loud failure. +func ParseFieldRules(b []byte) (map[string]FieldRule, error) { + if len(b) == 0 { + return map[string]FieldRule{}, nil + } + var raw map[string]fieldRuleJSON + if err := json.Unmarshal(b, &raw); err != nil { + return nil, fmt.Errorf("parse field rules: %w", err) + } + out := make(map[string]FieldRule, len(raw)) + for key, rule := range raw { + var level Requirement + switch strings.ToLower(strings.TrimSpace(rule.Level)) { + case "", "optional": + level = RequirementOptional + case "recommended": + level = RequirementRecommended + case "required": + level = RequirementRequired + default: + return nil, fmt.Errorf("unknown requirement level %q for slot %q", rule.Level, key) + } + out[key] = FieldRule{Level: level, Note: rule.Note} + } + return out, nil +} + // unmarshalStrict decodes YAML into v, rejecting unknown keys. Template // definition files are ours and their key set is fixed, so a typo must fail // loudly rather than leave a rule silently unset. diff --git a/backend/internal/frames/templates_service.go b/backend/internal/frames/templates_service.go new file mode 100644 index 0000000..b89c5fc --- /dev/null +++ b/backend/internal/frames/templates_service.go @@ -0,0 +1,177 @@ +package frames + +import ( + "context" + "errors" + "fmt" + + "connectrpc.com/connect" + + "github.com/nebari-dev/nebari-frames/backend/internal/rbac" + "github.com/nebari-dev/nebari-frames/backend/internal/store" + framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1" +) + +// The five template RPCs. Kept out of service.go, which is already large, +// following the precedent of admin.go. +// +// Templates are an authoring affordance, not Frames, so there are no per-item +// grants: org membership is the only access control, and it is enforced by +// passing the caller's org into every store accessor. Managing templates is +// admin-only because a template is an org standard, which puts it beside member +// management rather than beside Frame authoring. + +// requirementToProto converts the level for the wire. The proto enum has an +// explicit UNSPECIFIED that maps to optional, so an older client that leaves +// the field unset gets the permissive reading rather than an accidental +// requirement. The reverse direction belongs to the write handlers in Task 8, +// which is the only place anything decodes a caller-supplied level. +func requirementToProto(r Requirement) framesv1.Requirement { + switch r { + case RequirementRecommended: + return framesv1.Requirement_REQUIREMENT_RECOMMENDED + case RequirementRequired: + return framesv1.Requirement_REQUIREMENT_REQUIRED + default: + return framesv1.Requirement_REQUIREMENT_OPTIONAL + } +} + +func fieldRulesToProto(rules map[string]FieldRule) map[string]*framesv1.FieldRule { + if len(rules) == 0 { + return nil + } + out := make(map[string]*framesv1.FieldRule, len(rules)) + for key, rule := range rules { + out[key] = &framesv1.FieldRule{Level: requirementToProto(rule.Level), Note: rule.Note} + } + return out +} + +func templateSummaryToProto(t *Template) *framesv1.FrameTemplateSummary { + return &framesv1.FrameTemplateSummary{ + Id: t.ID, Title: t.Title, Description: t.Description, Builtin: IsBuiltin(t.ID), + } +} + +func templateToProto(t *Template) (*framesv1.FrameTemplate, error) { + prefill, err := MarshalPrefill(t.Prefill) + if err != nil { + return nil, err + } + return &framesv1.FrameTemplate{ + Id: t.ID, Title: t.Title, Description: t.Description, Builtin: IsBuiltin(t.ID), + Prefill: prefill, FieldRules: fieldRulesToProto(t.FieldRules), + }, nil +} + +// rowToTemplate decodes a stored row into the internal model. +func rowToTemplate(row *store.FrameTemplate) (*Template, error) { + prefill, err := ParsePrefill(row.Prefill) + if err != nil { + return nil, fmt.Errorf("template %s: %w", row.ID, err) + } + rules, err := ParseFieldRules(row.FieldRules) + if err != nil { + return nil, fmt.Errorf("template %s: %w", row.ID, err) + } + return &Template{ + ID: row.ID, Title: row.Title, Description: row.Description, + Prefill: prefill, FieldRules: rules, + }, nil +} + +// lookupTemplate resolves an id to a template the caller may use: a built-in, or +// a row in the caller's own org. Everything else, including another org's row, +// reports NotFound so existence does not leak - the same rule frames follow. +func (s *Service) lookupTemplate(ctx context.Context, caller rbac.Caller, id string) (*Template, error) { + if id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("id is required")) + } + if IsBuiltin(id) { + tmpl, ok := BuiltinTemplate(id) + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no template %q", id)) + } + return &tmpl, nil + } + row, err := s.repo.GetFrameTemplate(ctx, caller.OrgID, id) + if errors.Is(err, store.ErrNotFound) { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no template %q", id)) + } + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + tmpl, err := rowToTemplate(row) + if err != nil { + // A row this server wrote failed to decode, so the data is corrupt + // rather than the request wrong. + return nil, connect.NewError(connect.CodeInternal, err) + } + return tmpl, nil +} + +func (s *Service) ListFrameTemplates(ctx context.Context, _ *connect.Request[framesv1.ListFrameTemplatesRequest]) (*connect.Response[framesv1.ListFrameTemplatesResponse], error) { + caller, err := s.resolveCaller(ctx) + if err != nil { + return nil, err + } + rows, err := s.repo.ListFrameTemplatesByOrg(ctx, caller.OrgID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + // Built-ins first (Blank leads), then the org's own. No shadowing: an org + // template sharing a built-in's title sits beside it rather than replacing + // it, because a precedence rule here would be a permanent source of "why am + // I seeing the wrong one". The UI groups them instead. + out := make([]*framesv1.FrameTemplateSummary, 0, len(rows)+6) + for _, tmpl := range BuiltinTemplates() { + out = append(out, templateSummaryToProto(&tmpl)) + } + for _, row := range rows { + out = append(out, &framesv1.FrameTemplateSummary{ + Id: row.ID, Title: row.Title, Description: row.Description, Builtin: false, + }) + } + return connect.NewResponse(&framesv1.ListFrameTemplatesResponse{ + Templates: out, + CanManage: caller.Role == rbac.RoleAdmin, + }), nil +} + +func (s *Service) GetFrameTemplate(ctx context.Context, req *connect.Request[framesv1.GetFrameTemplateRequest]) (*connect.Response[framesv1.GetFrameTemplateResponse], error) { + caller, err := s.resolveCaller(ctx) + if err != nil { + return nil, err + } + tmpl, err := s.lookupTemplate(ctx, caller, req.Msg.Id) + if err != nil { + return nil, err + } + pb, err := templateToProto(tmpl) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&framesv1.GetFrameTemplateResponse{Template: pb}), nil +} + +// TEMPORARY (Task 7): placeholder so the FrameServiceHandler assertion in service.go +// compiles. Task 8 replaces this with the real handler. If you are reading this in +// merged code, it escaped review. +func (s *Service) CreateFrameTemplate(context.Context, *connect.Request[framesv1.CreateFrameTemplateRequest]) (*connect.Response[framesv1.CreateFrameTemplateResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("not implemented until Task 8")) +} + +// TEMPORARY (Task 7): placeholder so the FrameServiceHandler assertion in service.go +// compiles. Task 8 replaces this with the real handler. If you are reading this in +// merged code, it escaped review. +func (s *Service) UpdateFrameTemplate(context.Context, *connect.Request[framesv1.UpdateFrameTemplateRequest]) (*connect.Response[framesv1.UpdateFrameTemplateResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("not implemented until Task 8")) +} + +// TEMPORARY (Task 7): placeholder so the FrameServiceHandler assertion in service.go +// compiles. Task 8 replaces this with the real handler. If you are reading this in +// merged code, it escaped review. +func (s *Service) DeleteFrameTemplate(context.Context, *connect.Request[framesv1.DeleteFrameTemplateRequest]) (*connect.Response[framesv1.DeleteFrameTemplateResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("not implemented until Task 8")) +} diff --git a/backend/internal/frames/templates_service_test.go b/backend/internal/frames/templates_service_test.go new file mode 100644 index 0000000..7ae1ca0 --- /dev/null +++ b/backend/internal/frames/templates_service_test.go @@ -0,0 +1,251 @@ +package frames + +import ( + "context" + "strings" + "testing" + "time" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/nebari-dev/nebari-frames/backend/internal/auth" + "github.com/nebari-dev/nebari-frames/backend/internal/store" + framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1" +) + +// templateFixture builds a service with two orgs and a caller in each, so +// org-scoping assertions always have a control that must succeed. +type templateFixture struct { + svc *Service + repo *store.Memory +} + +func newTemplateFixture(t *testing.T, role string) (templateFixture, context.Context) { + t.Helper() + ctx := context.Background() + repo := store.NewMemory() + for _, spec := range []struct{ id, slug string }{{"org-a", "acme"}, {"org-b", "other"}} { + if err := repo.CreateOrg(ctx, &framesv1.Org{ + Id: spec.id, Slug: spec.slug, DisplayName: spec.slug, CreatedAt: timestamppb.Now(), + }); err != nil { + t.Fatalf("create org %s: %v", spec.id, err) + } + } + if err := repo.UpsertMembership(ctx, &framesv1.Membership{ + OrgId: "org-a", UserSub: "user-a", Role: role, AddedAt: timestamppb.Now(), + }); err != nil { + t.Fatalf("membership: %v", err) + } + callerCtx := auth.WithClaims(ctx, &auth.Claims{Subject: "user-a", Email: "a@example.com"}) + return templateFixture{svc: NewService(repo), repo: repo}, callerCtx +} + +func seedOrgTemplate(t *testing.T, repo *store.Memory, id, orgID, title string, rules map[string]FieldRule) { + t.Helper() + rulesJSON, err := MarshalFieldRules(rules) + if err != nil { + t.Fatalf("marshal rules: %v", err) + } + prefill, err := MarshalPrefill(Prefill{Slots: Slots{Style: "Plain sentences."}}) + if err != nil { + t.Fatalf("marshal prefill: %v", err) + } + now := time.Unix(1700000000, 0).UTC() + if err := repo.CreateFrameTemplate(context.Background(), &store.FrameTemplate{ + ID: id, OrgID: orgID, Title: title, Description: "seeded", + Prefill: prefill, FieldRules: rulesJSON, CreatedBy: "user-a", + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed template %s: %v", id, err) + } +} + +func TestListFrameTemplatesIncludesBuiltinsAndOrgRows(t *testing.T) { + f, ctx := newTemplateFixture(t, "admin") + seedOrgTemplate(t, f.repo, "t1", "org-a", "Our House Style", nil) + seedOrgTemplate(t, f.repo, "t2", "org-b", "Not Yours", nil) + + resp, err := f.svc.ListFrameTemplates(ctx, connect.NewRequest(&framesv1.ListFrameTemplatesRequest{})) + if err != nil { + t.Fatalf("list: %v", err) + } + byID := map[string]*framesv1.FrameTemplateSummary{} + for _, s := range resp.Msg.Templates { + byID[s.Id] = s + } + // Built-ins come first, Blank leading, so the picker is stable. + if len(resp.Msg.Templates) == 0 || resp.Msg.Templates[0].Id != BlankTemplateID { + t.Fatalf("first entry = %v, want %q", resp.Msg.Templates, BlankTemplateID) + } + if !byID[BlankTemplateID].Builtin { + t.Error("blank is not flagged builtin") + } + own, ok := byID["t1"] + if !ok { + t.Fatal("the caller's own org template is missing") + } + if own.Builtin { + t.Error("an org template is flagged builtin") + } + if own.Title != "Our House Style" { + t.Errorf("title = %q", own.Title) + } + // The other org's row must not appear. This is the assertion that matters; + // the presence checks above are its control. + if _, leaked := byID["t2"]; leaked { + t.Error("another org's template appeared in the list") + } + if !resp.Msg.CanManage { + t.Error("can_manage = false for an admin") + } +} + +func TestListFrameTemplatesCanManageFollowsRole(t *testing.T) { + tests := []struct { + role string + wantCanManage bool + }{ + {role: "admin", wantCanManage: true}, + {role: "publisher", wantCanManage: false}, + {role: "viewer", wantCanManage: false}, + } + for _, tt := range tests { + t.Run(tt.role, func(t *testing.T) { + f, ctx := newTemplateFixture(t, tt.role) + resp, err := f.svc.ListFrameTemplates(ctx, connect.NewRequest(&framesv1.ListFrameTemplatesRequest{})) + if err != nil { + t.Fatalf("list as %s: %v", tt.role, err) + } + if resp.Msg.CanManage != tt.wantCanManage { + t.Errorf("can_manage = %v, want %v", resp.Msg.CanManage, tt.wantCanManage) + } + // Every role can still list: templates are readable to any member. + if len(resp.Msg.Templates) == 0 { + t.Error("no templates returned") + } + }) + } +} + +func TestGetFrameTemplate(t *testing.T) { + f, ctx := newTemplateFixture(t, "publisher") + seedOrgTemplate(t, f.repo, "t1", "org-a", "Our House Style", map[string]FieldRule{ + "style": {Level: RequirementRequired, Note: "Voice and tone."}, + }) + seedOrgTemplate(t, f.repo, "t2", "org-b", "Not Yours", nil) + + tests := []struct { + name string + id string + wantCode connect.Code + wantRule Requirement + wantNote string + }{ + {name: "own org template", id: "t1", wantRule: RequirementRequired, wantNote: "Voice and tone."}, + {name: "builtin", id: "builtin:domain-vocabulary", wantRule: RequirementRequired}, + {name: "another org's template is not found", id: "t2", wantCode: connect.CodeNotFound}, + {name: "missing builtin is not found", id: "builtin:nope", wantCode: connect.CodeNotFound}, + {name: "unknown id is not found", id: "01J000000000000000000000AB", wantCode: connect.CodeNotFound}, + {name: "empty id is invalid", id: "", wantCode: connect.CodeInvalidArgument}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := f.svc.GetFrameTemplate(ctx, connect.NewRequest(&framesv1.GetFrameTemplateRequest{Id: tt.id})) + if tt.wantCode != 0 { + if connect.CodeOf(err) != tt.wantCode { + t.Fatalf("code = %v (err %v), want %v", connect.CodeOf(err), err, tt.wantCode) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := resp.Msg.Template + if got.Id != tt.id { + t.Errorf("id = %q, want %q", got.Id, tt.id) + } + if got.Builtin != IsBuiltin(tt.id) { + t.Errorf("builtin = %v, want %v", got.Builtin, IsBuiltin(tt.id)) + } + if len(got.FieldRules) == 0 { + t.Fatal("no field rules returned") + } + if tt.wantNote != "" { + if got.FieldRules["style"].Note != tt.wantNote { + t.Errorf("note = %q, want %q", got.FieldRules["style"].Note, tt.wantNote) + } + } + // Prefill must come back as something ParsePrefill accepts, or the + // clients cannot seed a form from it. + if _, err := ParsePrefill(got.Prefill); err != nil { + t.Errorf("prefill is not parseable: %v\n%s", err, got.Prefill) + } + }) + } +} + +func TestFieldRulesJSONRoundTrip(t *testing.T) { + tests := []struct { + name string + rules map[string]FieldRule + }{ + {name: "nil", rules: nil}, + {name: "empty", rules: map[string]FieldRule{}}, + { + name: "every level", + rules: map[string]FieldRule{ + "style": {Level: RequirementRequired, Note: "Voice."}, + "rules": {Level: RequirementRecommended, Note: "Constraints."}, + "terminology": {Level: RequirementOptional}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := MarshalFieldRules(tt.rules) + if err != nil { + t.Fatalf("marshal: %v", err) + } + got, err := ParseFieldRules(b) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(got) != len(tt.rules) { + t.Fatalf("got %d rules, want %d (%s)", len(got), len(tt.rules), b) + } + for key, want := range tt.rules { + if got[key] != want { + t.Errorf("rule %q = %+v, want %+v", key, got[key], want) + } + } + }) + } +} + +func TestFieldRulesJSONStoresLevelNames(t *testing.T) { + // Stored by name, never by the enum's integer: reordering the constants + // must not silently reinterpret every row already on disk. + b, err := MarshalFieldRules(map[string]FieldRule{"style": {Level: RequirementRequired}}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if want := `"level":"required"`; !strings.Contains(string(b), want) { + t.Errorf("encoded as %s, want it to contain %s", b, want) + } +} + +func TestParseFieldRulesRejectsUnknownLevel(t *testing.T) { + _, err := ParseFieldRules([]byte(`{"style":{"level":"mandatory"}}`)) + if err == nil { + t.Fatal("an unknown level was accepted") + } + // Naming the offending value matters: this error reaches an admin editing a + // template, and "invalid input" would not tell them which level to fix. + if !strings.Contains(err.Error(), "unknown requirement level") { + t.Errorf("err = %v, want it to name the unknown level", err) + } + if !strings.Contains(err.Error(), "style") { + t.Errorf("err = %v, want it to name the slot", err) + } +} From 99ed6206faaa9ab9ae2472ffe39f421d5f0f4c23 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:54:21 +0200 Subject: [PATCH 09/43] feat(frames): let org admins create, edit, and delete Frame templates --- backend/internal/frames/templates_service.go | 187 ++++++++++++-- .../internal/frames/templates_service_test.go | 228 ++++++++++++++++++ cli/internal/testutil/stub_server.go | 52 +++- 3 files changed, 446 insertions(+), 21 deletions(-) diff --git a/backend/internal/frames/templates_service.go b/backend/internal/frames/templates_service.go index b89c5fc..a306512 100644 --- a/backend/internal/frames/templates_service.go +++ b/backend/internal/frames/templates_service.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "strings" + "time" "connectrpc.com/connect" @@ -155,23 +157,178 @@ func (s *Service) GetFrameTemplate(ctx context.Context, req *connect.Request[fra return connect.NewResponse(&framesv1.GetFrameTemplateResponse{Template: pb}), nil } -// TEMPORARY (Task 7): placeholder so the FrameServiceHandler assertion in service.go -// compiles. Task 8 replaces this with the real handler. If you are reading this in -// merged code, it escaped review. -func (s *Service) CreateFrameTemplate(context.Context, *connect.Request[framesv1.CreateFrameTemplateRequest]) (*connect.Response[framesv1.CreateFrameTemplateResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("not implemented until Task 8")) +// requirementFromProto reads the wire level. UNSPECIFIED maps to optional, so an +// older client that leaves the field unset gets the permissive reading rather +// than an accidental requirement. +func requirementFromProto(r framesv1.Requirement) Requirement { + switch r { + case framesv1.Requirement_REQUIREMENT_RECOMMENDED: + return RequirementRecommended + case framesv1.Requirement_REQUIREMENT_REQUIRED: + return RequirementRequired + default: + return RequirementOptional + } } -// TEMPORARY (Task 7): placeholder so the FrameServiceHandler assertion in service.go -// compiles. Task 8 replaces this with the real handler. If you are reading this in -// merged code, it escaped review. -func (s *Service) UpdateFrameTemplate(context.Context, *connect.Request[framesv1.UpdateFrameTemplateRequest]) (*connect.Response[framesv1.UpdateFrameTemplateResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("not implemented until Task 8")) +// fieldRulesFromProto converts and validates client-supplied rules. An unknown +// slot key is refused: silently keeping a rule that can never fire would let an +// admin believe they had imposed a standard they had not. +func fieldRulesFromProto(in map[string]*framesv1.FieldRule) (map[string]FieldRule, error) { + out := make(map[string]FieldRule, len(in)) + for key, rule := range in { + if _, ok := slotByKey(key); !ok { + return nil, fmt.Errorf("field_rules names unknown slot %q", key) + } + if rule == nil { + continue + } + out[key] = FieldRule{Level: requirementFromProto(rule.Level), Note: rule.Note} + } + return out, nil +} + +// templateInput is the validated, decoded form of a create or update request. +// Both RPCs take the same fields, so they share one validator: a rule that +// applied on create but not on update would let an admin edit their way around +// it. +type templateInput struct { + title string + description string + prefill []byte + rules []byte } -// TEMPORARY (Task 7): placeholder so the FrameServiceHandler assertion in service.go -// compiles. Task 8 replaces this with the real handler. If you are reading this in -// merged code, it escaped review. -func (s *Service) DeleteFrameTemplate(context.Context, *connect.Request[framesv1.DeleteFrameTemplateRequest]) (*connect.Response[framesv1.DeleteFrameTemplateResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("not implemented until Task 8")) +func validateTemplateInput(title, description string, prefill []byte, rules map[string]*framesv1.FieldRule) (templateInput, error) { + title = strings.TrimSpace(title) + description = strings.TrimSpace(description) + if title == "" { + return templateInput{}, connect.NewError(connect.CodeInvalidArgument, errors.New("title is required")) + } + if description == "" { + return templateInput{}, connect.NewError(connect.CodeInvalidArgument, errors.New("description is required")) + } + // An empty prefill is legitimate and means "seed no content", which is what + // every built-in does. Normalize it to a parseable document so the stored + // blob is always something ParsePrefill accepts. + if len(prefill) == 0 { + empty, err := MarshalPrefill(Prefill{}) + if err != nil { + return templateInput{}, connect.NewError(connect.CodeInternal, err) + } + prefill = empty + } else if _, err := ParsePrefill(prefill); err != nil { + return templateInput{}, connect.NewError(connect.CodeInvalidArgument, err) + } + decoded, err := fieldRulesFromProto(rules) + if err != nil { + return templateInput{}, connect.NewError(connect.CodeInvalidArgument, err) + } + rulesJSON, err := MarshalFieldRules(decoded) + if err != nil { + return templateInput{}, connect.NewError(connect.CodeInternal, err) + } + return templateInput{title: title, description: description, prefill: prefill, rules: rulesJSON}, nil +} + +// respondWithRow reads back what was written and returns it, so a caller always +// sees the stored form rather than an optimistic echo of its own request. +func (s *Service) respondWithRow(ctx context.Context, orgID, id string) (*framesv1.FrameTemplate, error) { + row, err := s.repo.GetFrameTemplate(ctx, orgID, id) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + tmpl, err := rowToTemplate(row) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return templateToProto(tmpl) +} + +func (s *Service) CreateFrameTemplate(ctx context.Context, req *connect.Request[framesv1.CreateFrameTemplateRequest]) (*connect.Response[framesv1.CreateFrameTemplateResponse], error) { + caller, err := s.requireAdmin(ctx) + if err != nil { + return nil, err + } + in, err := validateTemplateInput(req.Msg.Title, req.Msg.Description, req.Msg.Prefill, req.Msg.FieldRules) + if err != nil { + return nil, err + } + now := time.Now().UTC() + row := &store.FrameTemplate{ + ID: newID(), OrgID: caller.OrgID, Title: in.title, Description: in.description, + Prefill: in.prefill, FieldRules: in.rules, CreatedBy: caller.Subject, + CreatedAt: now, UpdatedAt: now, + } + if err := s.repo.CreateFrameTemplate(ctx, row); err != nil { + if errors.Is(err, store.ErrAlreadyExists) { + return nil, connect.NewError(connect.CodeAlreadyExists, + fmt.Errorf("a template titled %q already exists in this organization", in.title)) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + pb, err := s.respondWithRow(ctx, caller.OrgID, row.ID) + if err != nil { + return nil, err + } + return connect.NewResponse(&framesv1.CreateFrameTemplateResponse{Template: pb}), nil +} + +func (s *Service) UpdateFrameTemplate(ctx context.Context, req *connect.Request[framesv1.UpdateFrameTemplateRequest]) (*connect.Response[framesv1.UpdateFrameTemplateResponse], error) { + caller, err := s.requireAdmin(ctx) + if err != nil { + return nil, err + } + // Built-ins are compiled in, so there is no row to change. Refused for an + // admin too: this is a property of the artifact, not a permission. + if IsBuiltin(req.Msg.Id) { + return nil, connect.NewError(connect.CodeInvalidArgument, + errors.New("built-in templates cannot be edited; create an organization template instead")) + } + in, err := validateTemplateInput(req.Msg.Title, req.Msg.Description, req.Msg.Prefill, req.Msg.FieldRules) + if err != nil { + return nil, err + } + // The store matches on id AND org, so a row in another org is simply not + // found. There is no read-then-write window to lose a race in. + err = s.repo.UpdateFrameTemplate(ctx, &store.FrameTemplate{ + ID: req.Msg.Id, OrgID: caller.OrgID, Title: in.title, Description: in.description, + Prefill: in.prefill, FieldRules: in.rules, UpdatedAt: time.Now().UTC(), + }) + switch { + case errors.Is(err, store.ErrNotFound): + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no template %q", req.Msg.Id)) + case errors.Is(err, store.ErrAlreadyExists): + return nil, connect.NewError(connect.CodeAlreadyExists, + fmt.Errorf("a template titled %q already exists in this organization", in.title)) + case err != nil: + return nil, connect.NewError(connect.CodeInternal, err) + } + pb, err := s.respondWithRow(ctx, caller.OrgID, req.Msg.Id) + if err != nil { + return nil, err + } + return connect.NewResponse(&framesv1.UpdateFrameTemplateResponse{Template: pb}), nil +} + +func (s *Service) DeleteFrameTemplate(ctx context.Context, req *connect.Request[framesv1.DeleteFrameTemplateRequest]) (*connect.Response[framesv1.DeleteFrameTemplateResponse], error) { + caller, err := s.requireAdmin(ctx) + if err != nil { + return nil, err + } + if IsBuiltin(req.Msg.Id) { + return nil, connect.NewError(connect.CodeInvalidArgument, + errors.New("built-in templates cannot be deleted")) + } + // No dependency check, unlike DeleteFrame: a template is copied once at + // creation and nothing references it afterwards, so deleting one cannot + // affect any Frame made from it. + err = s.repo.DeleteFrameTemplate(ctx, caller.OrgID, req.Msg.Id) + if errors.Is(err, store.ErrNotFound) { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no template %q", req.Msg.Id)) + } + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&framesv1.DeleteFrameTemplateResponse{}), nil } diff --git a/backend/internal/frames/templates_service_test.go b/backend/internal/frames/templates_service_test.go index 7ae1ca0..1d9f557 100644 --- a/backend/internal/frames/templates_service_test.go +++ b/backend/internal/frames/templates_service_test.go @@ -249,3 +249,231 @@ func TestParseFieldRulesRejectsUnknownLevel(t *testing.T) { t.Errorf("err = %v, want it to name the slot", err) } } + +func TestCreateFrameTemplate(t *testing.T) { + f, ctx := newTemplateFixture(t, "admin") + prefill, err := MarshalPrefill(Prefill{Slots: Slots{Style: "Plain sentences."}}) + if err != nil { + t.Fatalf("marshal prefill: %v", err) + } + resp, err := f.svc.CreateFrameTemplate(ctx, connect.NewRequest(&framesv1.CreateFrameTemplateRequest{ + Title: "Our House Style", Description: "How we sound", Prefill: prefill, + FieldRules: map[string]*framesv1.FieldRule{ + "style": {Level: framesv1.Requirement_REQUIREMENT_REQUIRED, Note: "Voice and tone."}, + }, + })) + if err != nil { + t.Fatalf("create: %v", err) + } + got := resp.Msg.Template + if got.Id == "" || IsBuiltin(got.Id) { + t.Errorf("id = %q, want a non-builtin id", got.Id) + } + if got.Builtin { + t.Error("a created template is flagged builtin") + } + // It has to come back from a fresh read, not just from the create response. + fetched, err := f.svc.GetFrameTemplate(ctx, connect.NewRequest(&framesv1.GetFrameTemplateRequest{Id: got.Id})) + if err != nil { + t.Fatalf("get after create: %v", err) + } + if fetched.Msg.Template.FieldRules["style"].Level != framesv1.Requirement_REQUIREMENT_REQUIRED { + t.Errorf("stored level = %v", fetched.Msg.Template.FieldRules["style"].Level) + } + if fetched.Msg.Template.FieldRules["style"].Note != "Voice and tone." { + t.Errorf("stored note = %q", fetched.Msg.Template.FieldRules["style"].Note) + } +} + +func TestCreateFrameTemplateValidation(t *testing.T) { + goodPrefill, err := MarshalPrefill(Prefill{Slots: Slots{Style: "x"}}) + if err != nil { + t.Fatalf("marshal prefill: %v", err) + } + tests := []struct { + name string + req *framesv1.CreateFrameTemplateRequest + wantCode connect.Code + wantMsg string + }{ + { + name: "valid", + req: &framesv1.CreateFrameTemplateRequest{Title: "T", Description: "D", Prefill: goodPrefill}, + }, + { + name: "title is required", + req: &framesv1.CreateFrameTemplateRequest{Description: "D", Prefill: goodPrefill}, + wantCode: connect.CodeInvalidArgument, wantMsg: "title", + }, + { + name: "description is required", + req: &framesv1.CreateFrameTemplateRequest{Title: "T", Prefill: goodPrefill}, + wantCode: connect.CodeInvalidArgument, wantMsg: "description", + }, + { + name: "prefill must not carry identity", + req: &framesv1.CreateFrameTemplateRequest{ + Title: "T", Description: "D", Prefill: []byte("name: sneaky\nslots: {}\n"), + }, + wantCode: connect.CodeInvalidArgument, wantMsg: "must not set name", + }, + { + name: "an unknown slot in field_rules is refused", + req: &framesv1.CreateFrameTemplateRequest{ + Title: "T", Description: "D", Prefill: goodPrefill, + FieldRules: map[string]*framesv1.FieldRule{"nosuchslot": {Level: framesv1.Requirement_REQUIREMENT_REQUIRED}}, + }, + wantCode: connect.CodeInvalidArgument, wantMsg: "unknown slot", + }, + { + name: "empty prefill is accepted and means no seeded content", + req: &framesv1.CreateFrameTemplateRequest{Title: "T", Description: "D"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, ctx := newTemplateFixture(t, "admin") + _, err := f.svc.CreateFrameTemplate(ctx, connect.NewRequest(tt.req)) + if tt.wantCode == 0 { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if connect.CodeOf(err) != tt.wantCode { + t.Fatalf("code = %v (err %v), want %v", connect.CodeOf(err), err, tt.wantCode) + } + if tt.wantMsg != "" && !strings.Contains(err.Error(), tt.wantMsg) { + t.Errorf("err = %v, want it to mention %q", err, tt.wantMsg) + } + }) + } +} + +func TestCreateFrameTemplateDuplicateTitle(t *testing.T) { + f, ctx := newTemplateFixture(t, "admin") + req := &framesv1.CreateFrameTemplateRequest{Title: "Only One", Description: "D"} + if _, err := f.svc.CreateFrameTemplate(ctx, connect.NewRequest(req)); err != nil { + t.Fatalf("first create: %v", err) + } + _, err := f.svc.CreateFrameTemplate(ctx, connect.NewRequest(req)) + if connect.CodeOf(err) != connect.CodeAlreadyExists { + t.Fatalf("code = %v (err %v), want AlreadyExists", connect.CodeOf(err), err) + } +} + +func TestTemplateWritesAreAdminOnly(t *testing.T) { + roles := []struct { + role string + wantAllow bool + }{ + {role: "admin", wantAllow: true}, + {role: "publisher", wantAllow: false}, + {role: "viewer", wantAllow: false}, + } + for _, tt := range roles { + t.Run(tt.role, func(t *testing.T) { + f, ctx := newTemplateFixture(t, tt.role) + seedOrgTemplate(t, f.repo, "t1", "org-a", "Existing", nil) + + _, createErr := f.svc.CreateFrameTemplate(ctx, connect.NewRequest( + &framesv1.CreateFrameTemplateRequest{Title: "New", Description: "D"})) + _, updateErr := f.svc.UpdateFrameTemplate(ctx, connect.NewRequest( + &framesv1.UpdateFrameTemplateRequest{Id: "t1", Title: "Renamed", Description: "D"})) + _, deleteErr := f.svc.DeleteFrameTemplate(ctx, connect.NewRequest( + &framesv1.DeleteFrameTemplateRequest{Id: "t1"})) + + for name, err := range map[string]error{"create": createErr, "update": updateErr, "delete": deleteErr} { + if tt.wantAllow { + if err != nil { + t.Errorf("%s as %s = %v, want success", name, tt.role, err) + } + continue + } + if connect.CodeOf(err) != connect.CodePermissionDenied { + t.Errorf("%s as %s: code = %v (err %v), want PermissionDenied", name, tt.role, connect.CodeOf(err), err) + } + } + // The control: a non-admin can still read, or the assertions above + // would pass for a caller who simply has no access at all. + if _, err := f.svc.ListFrameTemplates(ctx, connect.NewRequest(&framesv1.ListFrameTemplatesRequest{})); err != nil { + t.Errorf("list as %s = %v, want success", tt.role, err) + } + }) + } +} + +func TestBuiltinTemplatesAreImmutable(t *testing.T) { + f, ctx := newTemplateFixture(t, "admin") + _, updateErr := f.svc.UpdateFrameTemplate(ctx, connect.NewRequest(&framesv1.UpdateFrameTemplateRequest{ + Id: BlankTemplateID, Title: "Hijacked", Description: "D", + })) + if connect.CodeOf(updateErr) != connect.CodeInvalidArgument { + t.Errorf("update builtin: code = %v (err %v), want InvalidArgument", connect.CodeOf(updateErr), updateErr) + } + _, deleteErr := f.svc.DeleteFrameTemplate(ctx, connect.NewRequest(&framesv1.DeleteFrameTemplateRequest{ + Id: BlankTemplateID, + })) + if connect.CodeOf(deleteErr) != connect.CodeInvalidArgument { + t.Errorf("delete builtin: code = %v (err %v), want InvalidArgument", connect.CodeOf(deleteErr), deleteErr) + } + // The control: the same admin can mutate one of their own. + seedOrgTemplate(t, f.repo, "t1", "org-a", "Mine", nil) + if _, err := f.svc.DeleteFrameTemplate(ctx, connect.NewRequest(&framesv1.DeleteFrameTemplateRequest{Id: "t1"})); err != nil { + t.Errorf("delete own template = %v, want success", err) + } + // And the built-in is still there afterwards. + if _, ok := BuiltinTemplate(BlankTemplateID); !ok { + t.Error("the built-in vanished") + } +} + +func TestUpdateAndDeleteAreOrgScoped(t *testing.T) { + f, ctx := newTemplateFixture(t, "admin") + seedOrgTemplate(t, f.repo, "theirs", "org-b", "Theirs", nil) + _, updateErr := f.svc.UpdateFrameTemplate(ctx, connect.NewRequest(&framesv1.UpdateFrameTemplateRequest{ + Id: "theirs", Title: "Stolen", Description: "D", + })) + if connect.CodeOf(updateErr) != connect.CodeNotFound { + t.Errorf("update across orgs: code = %v (err %v), want NotFound", connect.CodeOf(updateErr), updateErr) + } + _, deleteErr := f.svc.DeleteFrameTemplate(ctx, connect.NewRequest(&framesv1.DeleteFrameTemplateRequest{Id: "theirs"})) + if connect.CodeOf(deleteErr) != connect.CodeNotFound { + t.Errorf("delete across orgs: code = %v (err %v), want NotFound", connect.CodeOf(deleteErr), deleteErr) + } + // The other org's row survived. + if _, err := f.repo.GetFrameTemplate(context.Background(), "org-b", "theirs"); err != nil { + t.Errorf("the other org's template was affected: %v", err) + } +} + +func TestOrgTemplateIsVisibleToAnotherMember(t *testing.T) { + // Journey 5's "persist and are usable by org members" half. A second member + // of the same org, not the author, must see it. + f, ctx := newTemplateFixture(t, "admin") + created, err := f.svc.CreateFrameTemplate(ctx, connect.NewRequest(&framesv1.CreateFrameTemplateRequest{ + Title: "Shared Standard", Description: "D", + })) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := f.repo.UpsertMembership(context.Background(), &framesv1.Membership{ + OrgId: "org-a", UserSub: "user-second", Role: "viewer", AddedAt: timestamppb.Now(), + }); err != nil { + t.Fatalf("second membership: %v", err) + } + otherCtx := auth.WithClaims(context.Background(), &auth.Claims{Subject: "user-second", Email: "s@example.com"}) + resp, err := f.svc.ListFrameTemplates(otherCtx, connect.NewRequest(&framesv1.ListFrameTemplatesRequest{})) + if err != nil { + t.Fatalf("list as the second member: %v", err) + } + found := false + for _, s := range resp.Msg.Templates { + if s.Id == created.Msg.Template.Id { + found = true + } + } + if !found { + t.Error("a second member of the org cannot see the template") + } +} diff --git a/cli/internal/testutil/stub_server.go b/cli/internal/testutil/stub_server.go index 4f7b6a0..625fc9e 100644 --- a/cli/internal/testutil/stub_server.go +++ b/cli/internal/testutil/stub_server.go @@ -15,12 +15,17 @@ import ( // StubService implements framesv1connect.FrameServiceHandler via optional func // fields. Unset methods return CodeUnimplemented. type StubService struct { - PublishFn func(context.Context, *connect.Request[framesv1.PublishFrameRequest]) (*connect.Response[framesv1.PublishFrameResponse], error) - ListFn func(context.Context, *connect.Request[framesv1.ListFramesRequest]) (*connect.Response[framesv1.ListFramesResponse], error) - GetFn func(context.Context, *connect.Request[framesv1.GetFrameRequest]) (*connect.Response[framesv1.GetFrameResponse], error) - ResolveFn func(context.Context, *connect.Request[framesv1.ResolveFrameRequest]) (*connect.Response[framesv1.ResolveFrameResponse], error) - MeFn func(context.Context, *connect.Request[framesv1.GetMeRequest]) (*connect.Response[framesv1.GetMeResponse], error) - ListVersionsFn func(context.Context, *connect.Request[framesv1.ListFrameVersionsRequest]) (*connect.Response[framesv1.ListFrameVersionsResponse], error) + PublishFn func(context.Context, *connect.Request[framesv1.PublishFrameRequest]) (*connect.Response[framesv1.PublishFrameResponse], error) + ListFn func(context.Context, *connect.Request[framesv1.ListFramesRequest]) (*connect.Response[framesv1.ListFramesResponse], error) + GetFn func(context.Context, *connect.Request[framesv1.GetFrameRequest]) (*connect.Response[framesv1.GetFrameResponse], error) + ResolveFn func(context.Context, *connect.Request[framesv1.ResolveFrameRequest]) (*connect.Response[framesv1.ResolveFrameResponse], error) + MeFn func(context.Context, *connect.Request[framesv1.GetMeRequest]) (*connect.Response[framesv1.GetMeResponse], error) + ListVersionsFn func(context.Context, *connect.Request[framesv1.ListFrameVersionsRequest]) (*connect.Response[framesv1.ListFrameVersionsResponse], error) + ListTemplatesFn func(context.Context, *connect.Request[framesv1.ListFrameTemplatesRequest]) (*connect.Response[framesv1.ListFrameTemplatesResponse], error) + GetTemplateFn func(context.Context, *connect.Request[framesv1.GetFrameTemplateRequest]) (*connect.Response[framesv1.GetFrameTemplateResponse], error) + CreateTemplateFn func(context.Context, *connect.Request[framesv1.CreateFrameTemplateRequest]) (*connect.Response[framesv1.CreateFrameTemplateResponse], error) + UpdateTemplateFn func(context.Context, *connect.Request[framesv1.UpdateFrameTemplateRequest]) (*connect.Response[framesv1.UpdateFrameTemplateResponse], error) + DeleteTemplateFn func(context.Context, *connect.Request[framesv1.DeleteFrameTemplateRequest]) (*connect.Response[framesv1.DeleteFrameTemplateResponse], error) } var _ framesv1connect.FrameServiceHandler = (*StubService)(nil) @@ -94,6 +99,41 @@ func (s *StubService) RemoveOrgMember(_ context.Context, r *connect.Request[fram return nil, unimpl() } +func (s *StubService) ListFrameTemplates(ctx context.Context, r *connect.Request[framesv1.ListFrameTemplatesRequest]) (*connect.Response[framesv1.ListFrameTemplatesResponse], error) { + if s.ListTemplatesFn != nil { + return s.ListTemplatesFn(ctx, r) + } + return nil, unimpl() +} + +func (s *StubService) GetFrameTemplate(ctx context.Context, r *connect.Request[framesv1.GetFrameTemplateRequest]) (*connect.Response[framesv1.GetFrameTemplateResponse], error) { + if s.GetTemplateFn != nil { + return s.GetTemplateFn(ctx, r) + } + return nil, unimpl() +} + +func (s *StubService) CreateFrameTemplate(ctx context.Context, r *connect.Request[framesv1.CreateFrameTemplateRequest]) (*connect.Response[framesv1.CreateFrameTemplateResponse], error) { + if s.CreateTemplateFn != nil { + return s.CreateTemplateFn(ctx, r) + } + return nil, unimpl() +} + +func (s *StubService) UpdateFrameTemplate(ctx context.Context, r *connect.Request[framesv1.UpdateFrameTemplateRequest]) (*connect.Response[framesv1.UpdateFrameTemplateResponse], error) { + if s.UpdateTemplateFn != nil { + return s.UpdateTemplateFn(ctx, r) + } + return nil, unimpl() +} + +func (s *StubService) DeleteFrameTemplate(ctx context.Context, r *connect.Request[framesv1.DeleteFrameTemplateRequest]) (*connect.Response[framesv1.DeleteFrameTemplateResponse], error) { + if s.DeleteTemplateFn != nil { + return s.DeleteTemplateFn(ctx, r) + } + return nil, unimpl() +} + // NewStubServer mounts h on an httptest.Server and returns the base URL. func NewStubServer(t *testing.T, h framesv1connect.FrameServiceHandler) string { t.Helper() From 0aab1cf784c9fbead93fd3f205825062f3361af0 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:58:22 +0200 Subject: [PATCH 10/43] fix(frames): drop stale Task 8 reference from requirementToProto comment --- backend/internal/frames/templates_service.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/frames/templates_service.go b/backend/internal/frames/templates_service.go index a306512..9add20b 100644 --- a/backend/internal/frames/templates_service.go +++ b/backend/internal/frames/templates_service.go @@ -26,8 +26,8 @@ import ( // requirementToProto converts the level for the wire. The proto enum has an // explicit UNSPECIFIED that maps to optional, so an older client that leaves // the field unset gets the permissive reading rather than an accidental -// requirement. The reverse direction belongs to the write handlers in Task 8, -// which is the only place anything decodes a caller-supplied level. +// requirement. requirementFromProto below is the reverse direction, used by the +// write handlers: they are the only place a caller-supplied level is decoded. func requirementToProto(r Requirement) framesv1.Requirement { switch r { case RequirementRecommended: From 6de11f89740172437c3c075064c7795660285d09 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:07:22 +0200 Subject: [PATCH 11/43] feat(frames): check a template's required slots when creating a Frame from it Publishing with template_id now asserts create intent and refuses the publish when a required slot is empty, merging those violations with any schema errors into one response. An unusable template_id (unknown or another org's) is reported as an invalid publish request, not a missing template, so existence does not leak. PublishDoc and PublishDocFrom keep their existing signatures as thin wrappers over the new PublishDocRequest/PublishRequest entry point, so no existing caller changes. --- .../internal/frames/publish_template_test.go | 208 ++++++++++++++++++ backend/internal/frames/service.go | 94 ++++++-- 2 files changed, 286 insertions(+), 16 deletions(-) create mode 100644 backend/internal/frames/publish_template_test.go diff --git a/backend/internal/frames/publish_template_test.go b/backend/internal/frames/publish_template_test.go new file mode 100644 index 0000000..a968e16 --- /dev/null +++ b/backend/internal/frames/publish_template_test.go @@ -0,0 +1,208 @@ +package frames + +import ( + "errors" + "testing" + + "connectrpc.com/connect" + + framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1" +) + +// These tests exercise PublishDocRequest's template check. They live in +// package frames, alongside templates_service_test.go, rather than in the +// external frames_test package that service_test.go uses: newTemplateFixture +// and seedOrgTemplate are unexported, so an external test package cannot reach +// them, and duplicating those fixtures here would defeat the point of reusing +// them. + +func TestPublishChecksTemplateRequirements(t *testing.T) { + docWith := func(slots Slots) *Doc { + return &Doc{Name: "test-frame", Description: "A test frame", Version: "1.0.0", Slots: slots} + } + tests := []struct { + name string + templateID string + slots Slots + wantPaths []string + wantCode connect.Code + }{ + { + name: "no template means no template checks", + templateID: "", + slots: Slots{}, + }, + { + name: "required slot empty is refused, naming the slot", + templateID: "builtin:domain-vocabulary", + slots: Slots{}, + wantPaths: []string{"slots.terminology"}, + wantCode: connect.CodeInvalidArgument, + }, + { + name: "required slot filled publishes", + templateID: "builtin:domain-vocabulary", + slots: Slots{Terminology: []Term{{Term: "Frame", Definition: "A scoped context artifact."}}}, + }, + { + // domain-vocabulary marks `rules` recommended. Leaving it empty while + // the required slot is filled must still publish: recommended is + // guidance, and this is the case that proves it is not quietly + // enforced. Without the explicit nil this is the previous case again. + name: "a recommended slot left empty still publishes", + templateID: "builtin:domain-vocabulary", + slots: Slots{Terminology: []Term{{Term: "Frame", Definition: "A scoped context artifact."}}, Rules: nil}, + }, + { + name: "an unknown template id is refused", + templateID: "builtin:nope", + slots: Slots{}, + wantCode: connect.CodeInvalidArgument, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, ctx := newTemplateFixture(t, "admin") + _, _, err := f.svc.PublishDocRequest(ctx, docWith(tt.slots), PublishRequest{ + Intent: PublishCreate, TemplateID: tt.templateID, + }) + if tt.wantCode == 0 { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if connect.CodeOf(err) != tt.wantCode { + t.Fatalf("code = %v (err %v), want %v", connect.CodeOf(err), err, tt.wantCode) + } + if len(tt.wantPaths) == 0 { + return + } + // The violation must arrive as a FieldViolations detail keyed by the + // slot path, or the web form cannot put the message on the section. + var ce *connect.Error + if !errors.As(err, &ce) { + t.Fatalf("error is not a connect error: %v", err) + } + got := map[string]bool{} + for _, d := range ce.Details() { + msg, decodeErr := d.Value() + if decodeErr != nil { + continue + } + if fv, ok := msg.(*framesv1.FieldViolations); ok { + for _, v := range fv.Violations { + got[v.Field] = true + } + } + } + for _, want := range tt.wantPaths { + if !got[want] { + t.Errorf("no field violation for %q; got %v", want, got) + } + } + }) + } +} + +func TestPublishMergesSchemaAndTemplateViolations(t *testing.T) { + // An author fixing schema errors and only then discovering the template's + // requirements is a worse experience than seeing both at once. + f, ctx := newTemplateFixture(t, "admin") + _, _, err := f.svc.PublishDocRequest(ctx, &Doc{ + Name: "BAD NAME", Description: "", Version: "1.0.0", + }, PublishRequest{Intent: PublishCreate, TemplateID: "builtin:domain-vocabulary"}) + if err == nil { + t.Fatal("publish succeeded on an invalid document") + } + var ce *connect.Error + if !errors.As(err, &ce) { + t.Fatalf("not a connect error: %v", err) + } + got := map[string]bool{} + for _, d := range ce.Details() { + msg, decodeErr := d.Value() + if decodeErr != nil { + continue + } + if fv, ok := msg.(*framesv1.FieldViolations); ok { + for _, v := range fv.Violations { + got[v.Field] = true + } + } + } + for _, want := range []string{"name", "description", "slots.terminology"} { + if !got[want] { + t.Errorf("missing violation for %q; got %v", want, got) + } + } +} + +func TestPublishWithTemplateAgainstAnExistingFrameIsRefused(t *testing.T) { + f, ctx := newTemplateFixture(t, "admin") + doc := &Doc{ + Name: "test-frame", Description: "A test frame", Version: "1.0.0", + Slots: Slots{Terminology: []Term{{Term: "Frame", Definition: "A scoped context artifact."}}}, + } + if _, _, err := f.svc.PublishDocRequest(ctx, doc, PublishRequest{ + Intent: PublishCreate, TemplateID: "builtin:domain-vocabulary", + }); err != nil { + t.Fatalf("first publish: %v", err) + } + doc.Version = "1.0.1" + _, _, err := f.svc.PublishDocRequest(ctx, doc, PublishRequest{ + Intent: PublishCreate, TemplateID: "builtin:domain-vocabulary", + }) + if connect.CodeOf(err) != connect.CodeAlreadyExists { + t.Fatalf("code = %v (err %v), want AlreadyExists", connect.CodeOf(err), err) + } +} + +func TestAFrameFromATemplateIsOrdinaryAfterwards(t *testing.T) { + // Journey 9. Requirements are checked at creation only: nothing is recorded + // on the Frame, so a later version may empty the section, and deleting the + // template cannot affect it. + f, ctx := newTemplateFixture(t, "admin") + seedOrgTemplate(t, f.repo, "t1", "org-a", "Vocabulary", map[string]FieldRule{ + "terminology": {Level: RequirementRequired, Note: "Define your terms."}, + }) + doc := &Doc{ + Name: "test-frame", Description: "A test frame", Version: "1.0.0", + Slots: Slots{Terminology: []Term{{Term: "Frame", Definition: "A scoped context artifact."}}}, + } + if _, _, err := f.svc.PublishDocRequest(ctx, doc, PublishRequest{ + Intent: PublishCreate, TemplateID: "t1", + }); err != nil { + t.Fatalf("create from template: %v", err) + } + + // Delete the template out from under the Frame. + if _, err := f.svc.DeleteFrameTemplate(ctx, connect.NewRequest(&framesv1.DeleteFrameTemplateRequest{Id: "t1"})); err != nil { + t.Fatalf("delete template: %v", err) + } + + // A later version may empty the once-required section. + if _, _, err := f.svc.PublishDocRequest(ctx, &Doc{ + Name: "test-frame", Description: "A test frame", Version: "1.0.1", + }, PublishRequest{Intent: PublishUpdate}); err != nil { + t.Fatalf("publishing an update after the template was deleted: %v", err) + } +} + +func TestPublishRejectsAnotherOrgsTemplateID(t *testing.T) { + f, ctx := newTemplateFixture(t, "admin") + seedOrgTemplate(t, f.repo, "theirs", "org-b", "Theirs", nil) + _, _, err := f.svc.PublishDocRequest(ctx, &Doc{ + Name: "test-frame", Description: "A test frame", Version: "1.0.0", + }, PublishRequest{Intent: PublishCreate, TemplateID: "theirs"}) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("code = %v (err %v), want InvalidArgument", connect.CodeOf(err), err) + } + // The control: the caller's own template on the same call succeeds. + seedOrgTemplate(t, f.repo, "mine", "org-a", "Mine", nil) + if _, _, err := f.svc.PublishDocRequest(ctx, &Doc{ + Name: "test-frame", Description: "A test frame", Version: "1.0.0", + }, PublishRequest{Intent: PublishCreate, TemplateID: "mine"}); err != nil { + t.Fatalf("own template = %v, want success", err) + } +} diff --git a/backend/internal/frames/service.go b/backend/internal/frames/service.go index 8df6a8b..72d5aaf 100644 --- a/backend/internal/frames/service.go +++ b/backend/internal/frames/service.go @@ -116,10 +116,20 @@ func (s *Service) PublishFrame(ctx context.Context, req *connect.Request[framesv if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } + intent := PublishUpsert + if req.Msg.TemplateId != "" { + // Naming a template asserts this is a new Frame. A template is copied + // once at creation, so applying one to a Frame that already exists is a + // mistake rather than an update, and the existing create-intent branch + // below reports it as AlreadyExists with a message that says so. + intent = PublishCreate + } // The submitted bytes are stored verbatim rather than re-marshalled from // doc: an author's comments and formatting survive, and the digest stays // stable for a document that did not change. - frame, version, err := s.publish(ctx, caller, doc, req.Msg.Content, req.Msg.Changelog, PublishUpsert, "") + frame, version, err := s.publish(ctx, caller, doc, req.Msg.Content, PublishRequest{ + Changelog: req.Msg.Changelog, Intent: intent, TemplateID: req.Msg.TemplateId, + }) if err != nil { return nil, err } @@ -139,6 +149,24 @@ func (s *Service) authorizePublish(ctx context.Context) (rbac.Caller, error) { return caller, nil } +// PublishRequest carries what a publish needs beyond the document itself. A +// struct rather than more positional parameters: publish already takes seven, +// and the same reasoning behind the Option type on Service applies here - adding +// an input should not churn every call site. +type PublishRequest struct { + Changelog string + Intent PublishIntent + // BaseVersion is the version the caller read before composing doc. Empty + // means the caller did not check, which is the unguarded behaviour the + // Connect RPC has always had. + BaseVersion string + // TemplateID names the template this Frame is being created from. It is used + // to check that template's required slots and is then discarded: nothing is + // recorded on the Frame, and a later publish is never re-checked. Empty means + // no template and no checks. + TemplateID string +} + // PublishDoc validates and publishes doc as a new version, enforcing RBAC: // creating a frame needs the publisher or admin role, and writing to an // existing frame needs edit permission on it. It is the single write path @@ -148,38 +176,72 @@ func (s *Service) authorizePublish(ctx context.Context) (rbac.Caller, error) { // Errors are connect errors so both front doors can map them without // translation: PermissionDenied, InvalidArgument (with field violations), // AlreadyExists, NotFound, Internal. +// +// PublishDoc itself publishes with no concurrency check and no template. func (s *Service) PublishDoc(ctx context.Context, doc *Doc, changelog string, intent PublishIntent) (*framesv1.Frame, *framesv1.FrameVersion, error) { - return s.PublishDocFrom(ctx, doc, changelog, intent, "") + return s.PublishDocRequest(ctx, doc, PublishRequest{Changelog: changelog, Intent: intent}) } -// PublishDocFrom is PublishDoc with a concurrency check. baseVersion is the -// version the caller read before composing doc; the publish is rejected with -// CodeFailedPrecondition when the frame has moved on since. An empty -// baseVersion means the caller did not check, which is the unguarded behaviour -// the Connect RPC has always had. +// PublishDocFrom is PublishDoc with a concurrency check. See PublishRequest. // // A read-modify-write without this check silently loses one of two concurrent // updates: both merge onto the same base, both pick different version strings // so nothing collides, and both report success. func (s *Service) PublishDocFrom(ctx context.Context, doc *Doc, changelog string, intent PublishIntent, baseVersion string) (*framesv1.Frame, *framesv1.FrameVersion, error) { + return s.PublishDocRequest(ctx, doc, PublishRequest{ + Changelog: changelog, Intent: intent, BaseVersion: baseVersion, + }) +} + +// PublishDocRequest is the full-fidelity entry point. It marshals doc rather +// than storing caller bytes, so comments and formatting survive a CLI or web +// publish only on the Connect path, which passes its own content. +func (s *Service) PublishDocRequest(ctx context.Context, doc *Doc, req PublishRequest) (*framesv1.Frame, *framesv1.FrameVersion, error) { caller, err := s.authorizePublish(ctx) if err != nil { return nil, nil, err } content, err := Marshal(doc) if err != nil { - return nil, nil, connect.NewError(connect.CodeInternal, err) + return nil, nil, connect.NewError(connect.CodeInvalidArgument, err) } - return s.publish(ctx, caller, doc, content, changelog, intent, baseVersion) + return s.publish(ctx, caller, doc, content, req) } // publish is the shared implementation, called only with a caller that // authorizePublish has already cleared. content is the canonical stored form of // doc; callers holding the author's original bytes pass those so they are not // normalized away. -func (s *Service) publish(ctx context.Context, caller rbac.Caller, doc *Doc, content []byte, changelog string, intent PublishIntent, baseVersion string) (*framesv1.Frame, *framesv1.FrameVersion, error) { +func (s *Service) publish(ctx context.Context, caller rbac.Caller, doc *Doc, content []byte, req PublishRequest) (*framesv1.Frame, *framesv1.FrameVersion, error) { + // Schema violations are collected rather than returned straight away, so + // they can be reported together with the template's. An author who fixes + // three schema errors and only then discovers two more required sections has + // been made to do the work twice. + var fieldErrs []FieldError if verr := Validate(doc); verr != nil { - return nil, nil, violationErr(verr) + var ve *ValidationError + if !errors.As(verr, &ve) { + return nil, nil, violationErr(verr) + } + fieldErrs = ve.Errors + } + if req.TemplateID != "" { + tmpl, err := s.lookupTemplate(ctx, caller, req.TemplateID) + if err != nil { + // A template the caller cannot use makes the publish request wrong, + // not the template missing: what is being rejected is the Frame they + // asked to create. Another org's id lands here too, and reports the + // same thing an unknown id does so existence does not leak. + if code := connect.CodeOf(err); code == connect.CodeNotFound || code == connect.CodeInvalidArgument { + return nil, nil, connect.NewError(connect.CodeInvalidArgument, + fmt.Errorf("unknown template_id %q", req.TemplateID)) + } + return nil, nil, err + } + fieldErrs = append(fieldErrs, tmpl.Check(doc)...) + } + if len(fieldErrs) > 0 { + return nil, nil, violationErr(&ValidationError{Errors: fieldErrs}) } // Enforced here rather than at either entry point so the Connect API and the // MCP tools share one limit. It matters more now that an LLM can author a @@ -201,9 +263,9 @@ func (s *Service) publish(ctx context.Context, caller rbac.Caller, doc *Doc, con return nil, nil, connect.NewError(connect.CodeInternal, err) } switch { - case isNew && intent == PublishUpdate: + case isNew && req.Intent == PublishUpdate: return nil, nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no frame named %q to update", doc.Name)) - case !isNew && intent == PublishCreate: + case !isNew && req.Intent == PublishCreate: return nil, nil, connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("a frame named %q already exists; update it instead", doc.Name)) } @@ -223,10 +285,10 @@ func (s *Service) publish(ctx context.Context, caller rbac.Caller, doc *Doc, con if !allowed { return nil, nil, connect.NewError(connect.CodePermissionDenied, errors.New("edit permission required")) } - if baseVersion != "" && existing.LatestVersion != baseVersion { + if req.BaseVersion != "" && existing.LatestVersion != req.BaseVersion { return nil, nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf( "frame %q has moved on: you based this change on %s but the latest is %s; re-read it and apply your change again", - doc.Name, baseVersion, existing.LatestVersion)) + doc.Name, req.BaseVersion, existing.LatestVersion)) } // latest_version must only move forward. Otherwise publishing an older // version silently unpublishes newer content: every default read, and @@ -261,7 +323,7 @@ func (s *Service) publish(ctx context.Context, caller rbac.Caller, doc *Doc, con digest := sha256.Sum256(content) version := &framesv1.FrameVersion{ - Version: doc.Version, Changelog: changelog, Digest: hex.EncodeToString(digest[:]), + Version: doc.Version, Changelog: req.Changelog, Digest: hex.EncodeToString(digest[:]), SizeBytes: int64(len(content)), PublishedBy: caller.Subject, PublishedAt: now, Content: content, } From 85bda5462532944d74cb113c56067cfb2d5b4495 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:12:29 +0200 Subject: [PATCH 12/43] fix(frames): report a PublishDocRequest marshal failure as Internal, not InvalidArgument Marshal here serializes a document the server already built and validated, so a failure is a server-side invariant break, not bad caller input. Matches ConvertFrame's handling of the same call elsewhere in this file. --- backend/internal/frames/service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/frames/service.go b/backend/internal/frames/service.go index 72d5aaf..47047c1 100644 --- a/backend/internal/frames/service.go +++ b/backend/internal/frames/service.go @@ -203,7 +203,7 @@ func (s *Service) PublishDocRequest(ctx context.Context, doc *Doc, req PublishRe } content, err := Marshal(doc) if err != nil { - return nil, nil, connect.NewError(connect.CodeInvalidArgument, err) + return nil, nil, connect.NewError(connect.CodeInternal, err) } return s.publish(ctx, caller, doc, content, req) } From 54eca6b93448e2d4782737b2c2394ddcb1453347 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:27:51 +0200 Subject: [PATCH 13/43] feat(web): offer a template picker at the start of the create flow --- .../components/frame/TemplatePicker.test.tsx | 47 +++++++++++ web/src/components/frame/TemplatePicker.tsx | 59 +++++++++++++ web/src/lib/templates.test.ts | 82 +++++++++++++++++++ web/src/lib/templates.ts | 61 ++++++++++++++ .../pages/FrameAuthoringPage.create.test.tsx | 9 +- .../FrameAuthoringPage.markdown.test.tsx | 9 +- .../FrameAuthoringPage.template.test.tsx | 75 +++++++++++++++++ web/src/pages/FrameAuthoringPage.tsx | 22 ++++- 8 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 web/src/components/frame/TemplatePicker.test.tsx create mode 100644 web/src/components/frame/TemplatePicker.tsx create mode 100644 web/src/lib/templates.test.ts create mode 100644 web/src/lib/templates.ts create mode 100644 web/src/pages/FrameAuthoringPage.template.test.tsx diff --git a/web/src/components/frame/TemplatePicker.test.tsx b/web/src/components/frame/TemplatePicker.test.tsx new file mode 100644 index 0000000..b6f6ea2 --- /dev/null +++ b/web/src/components/frame/TemplatePicker.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { FrameTemplateSummarySchema } from "@gen/frames/v1/frame_pb"; +import { TemplatePicker } from "./TemplatePicker"; + +// Built with create() rather than object literals: the generated summary is a +// protobuf-es Message and carries a $typeName a literal cannot satisfy. This is +// the same pattern DeleteFrameDialog.test.tsx and publish-errors.test.ts use. +const templates = [ + create(FrameTemplateSummarySchema, { + id: "builtin:blank", title: "Blank", description: "Start from an empty document.", builtin: true, + }), + create(FrameTemplateSummarySchema, { + id: "builtin:brand-voice", title: "Brand Voice and Style", description: "How your org sounds.", builtin: true, + }), + create(FrameTemplateSummarySchema, { + id: "01JORGTEMPLATE0000000000AB", title: "Our House Style", description: "Ours.", builtin: false, + }), +]; + +it("groups the org's templates separately from the built-ins", () => { + render(); + expect(screen.getByRole("heading", { name: /your organization/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /built-in/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /our house style/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /blank/i })).toBeInTheDocument(); +}); + +it("omits the organization group when the org has no templates", () => { + render( t.builtin)} onPick={vi.fn()} />); + expect(screen.queryByRole("heading", { name: /your organization/i })).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /built-in/i })).toBeInTheDocument(); +}); + +it("shows each template's description", () => { + render(); + expect(screen.getByText("How your org sounds.")).toBeInTheDocument(); +}); + +it("reports the chosen template's id", async () => { + const onPick = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: /brand voice and style/i })); + expect(onPick).toHaveBeenCalledWith("builtin:brand-voice"); +}); diff --git a/web/src/components/frame/TemplatePicker.tsx b/web/src/components/frame/TemplatePicker.tsx new file mode 100644 index 0000000..b2652aa --- /dev/null +++ b/web/src/components/frame/TemplatePicker.tsx @@ -0,0 +1,59 @@ +import type { FrameTemplateSummary } from "@gen/frames/v1/frame_pb"; +import { Button } from "@/components/ui/button"; + +// Org templates and built-ins are shown in separate groups rather than merged +// or shadowed. An org template sharing a built-in's title sits beside it: a +// precedence rule would be a permanent source of "why am I seeing the wrong +// one", and grouping answers the question visually instead. +function Group({ + heading, + templates, + onPick, +}: { + heading: string; + templates: FrameTemplateSummary[]; + onPick: (id: string) => void; +}) { + if (templates.length === 0) return null; + return ( +
+

{heading}

+
+ {templates.map((t) => ( + + ))} +
+
+ ); +} + +export function TemplatePicker({ + templates, + onPick, +}: { + templates: FrameTemplateSummary[]; + onPick: (id: string) => void; +}) { + return ( +
+
+

What kind of Frame is this?

+

+ A template gives you a starting shape. Everything it fills in stays fully editable, and a + Frame keeps no link to the template it started from. +

+
+ !t.builtin)} onPick={onPick} /> + t.builtin)} onPick={onPick} /> +
+ ); +} diff --git a/web/src/lib/templates.test.ts b/web/src/lib/templates.test.ts new file mode 100644 index 0000000..bfd2a1c --- /dev/null +++ b/web/src/lib/templates.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { Requirement } from "@gen/frames/v1/frame_pb"; +import { seededSections, isRequiredSection, sectionHint, publishTemplateID, type TemplateRules } from "./templates"; +import { SLOT_SECTIONS } from "./slot-sections"; + +const terminology = SLOT_SECTIONS.find((s) => s.key === "terminology")!; + +describe("seededSections", () => { + it("returns nothing for a template with no rules", () => { + expect(seededSections({})).toEqual([]); + }); + + it("includes required and recommended sections but not optional ones", () => { + const rules: TemplateRules = { + terminology: { level: Requirement.REQUIRED, note: "" }, + rules: { level: Requirement.RECOMMENDED, note: "" }, + style: { level: Requirement.OPTIONAL, note: "" }, + }; + expect(seededSections(rules).map((s) => s.key)).toEqual(["terminology", "rules"]); + }); + + it("returns sections in schema order, not rule-map order", () => { + const rules: TemplateRules = { + business_process: { level: Requirement.REQUIRED, note: "" }, + terminology: { level: Requirement.REQUIRED, note: "" }, + style: { level: Requirement.REQUIRED, note: "" }, + }; + expect(seededSections(rules).map((s) => s.key)).toEqual(["terminology", "style", "business_process"]); + }); + + it("ignores a rule naming a slot that does not exist", () => { + const rules: TemplateRules = { nosuchslot: { level: Requirement.REQUIRED, note: "" } }; + expect(seededSections(rules)).toEqual([]); + }); +}); + +describe("publishTemplateID", () => { + // Which id a publish carries is a pure rule, so it is tested as one rather + // than inferred from whether a form submission happened to fire. + it.each([ + [{ mode: "create" as const, importing: false, templateID: "builtin:blank" }, "builtin:blank"], + // Edit never carries a template: requirements are checked on create only. + [{ mode: "edit" as const, importing: false, templateID: "builtin:blank" }, ""], + // The import path brought its own document; applying a standard the author + // never chose would be a surprise. + [{ mode: "create" as const, importing: true, templateID: "builtin:blank" }, ""], + [{ mode: "create" as const, importing: false, templateID: "" }, ""], + ])("%o -> %s", (input, want) => { + expect(publishTemplateID(input)).toBe(want); + }); +}); + +describe("isRequiredSection", () => { + it.each([ + [Requirement.REQUIRED, true], + [Requirement.RECOMMENDED, false], + [Requirement.OPTIONAL, false], + [Requirement.UNSPECIFIED, false], + ])("level %s -> %s", (level, want) => { + expect(isRequiredSection({ terminology: { level, note: "" } }, "terminology")).toBe(want); + }); + + it("is false for a slot with no rule", () => { + expect(isRequiredSection({}, "terminology")).toBe(false); + }); +}); + +describe("sectionHint", () => { + it("prefers the template's note over the generic hint", () => { + const rules: TemplateRules = { terminology: { level: Requirement.REQUIRED, note: "One entry per term of art." } }; + expect(sectionHint(rules, terminology)).toBe("One entry per term of art."); + }); + + it("falls back to the generic hint when the note is empty", () => { + const rules: TemplateRules = { terminology: { level: Requirement.REQUIRED, note: "" } }; + expect(sectionHint(rules, terminology)).toBe(terminology.hint); + }); + + it("falls back to the generic hint when there is no rule at all", () => { + expect(sectionHint({}, terminology)).toBe(terminology.hint); + }); +}); diff --git a/web/src/lib/templates.ts b/web/src/lib/templates.ts new file mode 100644 index 0000000..8d573e3 --- /dev/null +++ b/web/src/lib/templates.ts @@ -0,0 +1,61 @@ +import { Requirement } from "@gen/frames/v1/frame_pb"; +import { SLOT_SECTIONS, type SlotSectionDef } from "@/lib/slot-sections"; + +// A template's rules, keyed by slot. Shaped like the generated map field so the +// response can be handed straight in. +export type TemplateRules = Record; + +/** + * The sections a template wants an author to see up front: required and + * recommended, in schema order. Optional slots stay behind "+ Add section" - + * the point of a template is to narrow the decisions, not to reopen all ten. + * + * Driven off SLOT_SECTIONS rather than the rule map, so order is deterministic + * and a rule naming a slot that no longer exists is ignored rather than + * crashing the form. + */ +export function seededSections(rules: TemplateRules): SlotSectionDef[] { + return SLOT_SECTIONS.filter((def) => { + const level = rules[def.key]?.level; + return level === Requirement.REQUIRED || level === Requirement.RECOMMENDED; + }); +} + +/** + * The template id a publish should carry. + * + * Create only, and only when a template was actually chosen: the backend uses + * the id to check that template's required slots and then discards it, and that + * check applies to a new Frame. Edit must never send one, and neither must the + * .frame.md import path, whose document came from a file rather than a template. + */ +export function publishTemplateID({ + mode, + importing, + templateID, +}: { + mode: "create" | "edit"; + importing: boolean; + templateID: string; +}): string { + return mode === "create" && !importing ? templateID : ""; +} + +/** + * Whether a section may not be removed. A required section that an author + * deletes guarantees a publish failure, so the remove control is withheld + * rather than offered and then punished. + */ +export function isRequiredSection(rules: TemplateRules, key: string): boolean { + return rules[key]?.level === Requirement.REQUIRED; +} + +/** + * What to show under a section heading. The template author's note beats the + * generic per-slot hint: they know why their org wants this section, and the + * generic text does not. + */ +export function sectionHint(rules: TemplateRules, def: SlotSectionDef): string { + const note = rules[def.key]?.note?.trim(); + return note ? note : def.hint; +} diff --git a/web/src/pages/FrameAuthoringPage.create.test.tsx b/web/src/pages/FrameAuthoringPage.create.test.tsx index 9e6e0b4..9bc561c 100644 --- a/web/src/pages/FrameAuthoringPage.create.test.tsx +++ b/web/src/pages/FrameAuthoringPage.create.test.tsx @@ -30,8 +30,15 @@ function pointerClick(el: Element) { fireEvent.click(el); } +// The real create path now starts at the template picker; these tests exercise +// the authoring form itself, so they render past it with a template already +// chosen in the URL, same as a user who just picked one. function renderCreate() { - render(); + render( + + + , + ); } async function fillIdentity() { diff --git a/web/src/pages/FrameAuthoringPage.markdown.test.tsx b/web/src/pages/FrameAuthoringPage.markdown.test.tsx index 9e7a54a..3ba7e75 100644 --- a/web/src/pages/FrameAuthoringPage.markdown.test.tsx +++ b/web/src/pages/FrameAuthoringPage.markdown.test.tsx @@ -47,8 +47,15 @@ beforeEach(() => { publishMock.mockReset(); }); +// The real create path now starts at the template picker; these tests exercise +// the authoring form itself, so they render past it with a template already +// chosen in the URL, same as a user who just picked one. function renderCreate() { - return render(); + return render( + + + , + ); } // Markdown is a secondary mode behind the overflow menu, not a header toggle. diff --git a/web/src/pages/FrameAuthoringPage.template.test.tsx b/web/src/pages/FrameAuthoringPage.template.test.tsx new file mode 100644 index 0000000..2cc268b --- /dev/null +++ b/web/src/pages/FrameAuthoringPage.template.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { expect, it, vi, beforeEach } from "vitest"; + +const useQueryMock = vi.fn(); +const useMutationMock = vi.fn(); +// Arguments are forwarded so tests can route by which RPC was asked for. Routing +// by call order instead breaks the moment React re-renders, which it does on +// every keystroke in the form. +vi.mock("@connectrpc/connect-query", () => ({ + useQuery: (...args: unknown[]) => useQueryMock(...args), + useMutation: (...args: unknown[]) => useMutationMock(...args), + createConnectQueryKey: () => ["k"], +})); +vi.mock("@tanstack/react-query", async (importOriginal) => ({ + ...(await importOriginal()), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { FrameAuthoringPage } from "./FrameAuthoringPage"; + +const templateList = { + isLoading: false, + error: null, + data: { + canManage: false, + templates: [ + { id: "builtin:blank", title: "Blank", description: "Empty.", builtin: true }, + { id: "builtin:domain-vocabulary", title: "Domain Vocabulary", description: "Terms.", builtin: true }, + ], + }, +}; + +beforeEach(() => { + useQueryMock.mockReturnValue(templateList); + useMutationMock.mockReturnValue({ mutateAsync: vi.fn(), isPending: false }); +}); + +function renderAt(path: string) { + return render( + + + } /> + + , + ); +} + +it("shows the picker at /frames/new with no template chosen", () => { + renderAt("/frames/new"); + expect(screen.getByRole("heading", { name: /built-in/i })).toBeInTheDocument(); + // The authoring form must not be on the page yet. + expect(screen.queryByLabelText(/frame name/i)).not.toBeInTheDocument(); +}); + +it("navigates to the seeded form when a template is chosen", async () => { + renderAt("/frames/new"); + await userEvent.click(screen.getByRole("button", { name: /domain vocabulary/i })); + // Choosing a template puts it in the URL, so the choice is linkable and a + // reload does not lose it. + expect(screen.queryByRole("heading", { name: /built-in/i })).not.toBeInTheDocument(); + expect(screen.getByLabelText(/frame name/i)).toBeInTheDocument(); +}); + +it("skips the picker when a template is already in the URL", () => { + renderAt("/frames/new?template=builtin:blank"); + expect(screen.queryByRole("heading", { name: /built-in/i })).not.toBeInTheDocument(); + expect(screen.getByLabelText(/frame name/i)).toBeInTheDocument(); +}); + +it("skips the picker for the .frame.md import path", () => { + renderAt("/frames/new?import=1"); + expect(screen.queryByRole("heading", { name: /built-in/i })).not.toBeInTheDocument(); +}); diff --git a/web/src/pages/FrameAuthoringPage.tsx b/web/src/pages/FrameAuthoringPage.tsx index 98cf7ec..5a49244 100644 --- a/web/src/pages/FrameAuthoringPage.tsx +++ b/web/src/pages/FrameAuthoringPage.tsx @@ -20,6 +20,7 @@ import { MarkdownSourceEditor } from "@/components/form/MarkdownSourceEditor"; import { DocMetadataHeader } from "@/components/document/DocMetadataHeader"; import { AddSectionMenu } from "@/components/document/AddSectionMenu"; import { PublishDialog } from "@/components/document/PublishDialog"; +import { TemplatePicker } from "@/components/frame/TemplatePicker"; import { Button } from "@/components/ui/button"; import { Alert } from "@/components/ui/alert"; import { @@ -72,7 +73,7 @@ function SectionEditor({ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) { const navigate = useNavigate(); const queryClient = useQueryClient(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const [formError, setFormError] = useState(null); const methods = useForm({ @@ -92,6 +93,14 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) { const [markdownSource, setMarkdownSource] = useState(""); const [markdownErrors, setMarkdownErrors] = useState([]); + const templateID = searchParams.get("template") ?? ""; + // The picker is the first screen of the create flow. `?import=1` bypasses it: + // that path already has its source document, so asking for a starting shape + // would be nonsense. Edit mode never sees it - templates apply at creation. + const choosingTemplate = mode === "create" && !importing && templateID === ""; + + const templates = useQuery(FrameService.method.listFrameTemplates, {}); + // Sections the author added this session; content-bearing sections are // always visible regardless (which covers the async edit-mode prefill). const [added, setAdded] = useState>(new Set()); @@ -300,6 +309,17 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) { const title = mode === "edit" ? "Edit Frame" : importing ? "Import Frame" : "New Frame"; + if (choosingTemplate) { + return ( + setSearchParams({ template: id }, { replace: true })} + /> + ); + } + return (
e.preventDefault()} className="mx-auto max-w-3xl space-y-6"> From a24afbe8cb197419b907527fa40f8089396cb846 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:44:18 +0200 Subject: [PATCH 14/43] feat(web): seed the authoring form from the chosen template Once a template is picked, fetch it (getFrameTemplate), parse its prefill through the same parseFrameContent/docToForm steps the edit path already uses, and reset the form with the result. Sections the template marks required or recommended are pre-added rather than hidden behind "+ Add section", unioned with whatever the prefill itself populated so prefilled content is never collapsed. A required section's "Remove section" control is withheld, and a rule's note replaces the generic per-slot hint. Also widens frameDocSchema.name to default to "" like its sibling metadata fields: a template's prefill is a strict subset of the Frame schema that never sets identity fields, so the shared parser must decode a prefill missing name the same way it already tolerates a missing description or version. --- web/src/lib/frame-yaml.ts | 7 +- .../FrameAuthoringPage.template.test.tsx | 85 ++++++++++++++++++ web/src/pages/FrameAuthoringPage.tsx | 88 ++++++++++++++++--- 3 files changed, 167 insertions(+), 13 deletions(-) diff --git a/web/src/lib/frame-yaml.ts b/web/src/lib/frame-yaml.ts index 31f6c56..551ab55 100644 --- a/web/src/lib/frame-yaml.ts +++ b/web/src/lib/frame-yaml.ts @@ -27,7 +27,12 @@ export const VISIBILITY_VALUES = ["private", "internal", "shared", "public"] as export const DEFAULT_VISIBILITY = "internal"; export const frameDocSchema = z.object({ - name: z.string(), + // A template's prefill is a strict subset of this schema that never sets + // identity fields (backend/internal/frames/templates.go ParsePrefill), so + // name must decode from an absent key the same way its sibling metadata + // fields already do - otherwise the one shared parser used by both the + // edit path and template seeding would reject every prefill. + name: z.string().default(""), description: z.string().default(""), version: z.string().default(""), visibility: z.string().default(""), diff --git a/web/src/pages/FrameAuthoringPage.template.test.tsx b/web/src/pages/FrameAuthoringPage.template.test.tsx index 2cc268b..6085a7e 100644 --- a/web/src/pages/FrameAuthoringPage.template.test.tsx +++ b/web/src/pages/FrameAuthoringPage.template.test.tsx @@ -19,6 +19,7 @@ vi.mock("@tanstack/react-query", async (importOriginal) => ({ })); import { FrameAuthoringPage } from "./FrameAuthoringPage"; +import { FrameService } from "@gen/frames/v1/frame_service_pb"; const templateList = { isLoading: false, @@ -73,3 +74,87 @@ it("skips the picker for the .frame.md import path", () => { renderAt("/frames/new?import=1"); expect(screen.queryByRole("heading", { name: /built-in/i })).not.toBeInTheDocument(); }); + +const vocabularyTemplate = { + id: "builtin:domain-vocabulary", + title: "Domain Vocabulary", + description: "Terms.", + builtin: true, + prefill: new TextEncoder().encode("slots: {}\n"), + fieldRules: { + terminology: { level: 3, note: "One entry per term of art." }, + rules: { level: 2, note: "Usage constraints worth stating." }, + }, +}; + +const orgTemplateWithPrefill = { + id: "01JORGTEMPLATE0000000000AB", + title: "Our House Style", + description: "Ours.", + builtin: false, + prefill: new TextEncoder().encode( + "slots:\n terminology:\n - term: Frame\n definition: A scoped context artifact.\n - term: Slot\n definition: One section of a Frame.\n", + ), + fieldRules: { terminology: { level: 3, note: "Keep these, add your own." } }, +}; + +// Routes on the RPC descriptor's object identity rather than call order (the +// convention already used in FrameAuthoringPage.edit.test.tsx and +// FrameDetailPage.test.tsx), so the mock keeps answering correctly across the +// re-renders a keystroke in the form causes. +function mockQueries(template: unknown) { + useQueryMock.mockImplementation((method: unknown) => { + if (method === FrameService.method.getFrameTemplate) { + return { isLoading: false, error: null, data: { template } }; + } + return { isLoading: false, error: null, data: templateList.data }; + }); +} + +it("pre-adds the required and recommended sections and hides optional ones", () => { + mockQueries(vocabularyTemplate); + renderAt("/frames/new?template=builtin:domain-vocabulary"); + expect(screen.getByRole("heading", { name: /^terminology$/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /^rules$/i })).toBeInTheDocument(); + // Optional slots stay behind "+ Add section": a template narrows the + // decisions rather than reopening all ten. + expect(screen.queryByRole("heading", { name: /^architecture$/i })).not.toBeInTheDocument(); +}); + +it("shows the template's note in place of the generic hint", () => { + mockQueries(vocabularyTemplate); + renderAt("/frames/new?template=builtin:domain-vocabulary"); + expect(screen.getByText("One entry per term of art.")).toBeInTheDocument(); + expect(screen.getByText("Usage constraints worth stating.")).toBeInTheDocument(); +}); + +it("withholds the remove control on a required section but offers it on a recommended one", () => { + mockQueries(vocabularyTemplate); + renderAt("/frames/new?template=builtin:domain-vocabulary"); + // Removing a required section guarantees a publish failure, so it is not + // offered. A recommended one is genuinely optional. + const removes = screen.getAllByRole("button", { name: /remove section/i }); + expect(removes).toHaveLength(1); +}); + +it("populates prefilled content and lets it be edited", async () => { + mockQueries(orgTemplateWithPrefill); + renderAt("/frames/new?template=01JORGTEMPLATE0000000000AB"); + expect(screen.getByDisplayValue("Frame")).toBeInTheDocument(); + expect(screen.getByDisplayValue("A scoped context artifact.")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Slot")).toBeInTheDocument(); + const term = screen.getByDisplayValue("Frame"); + await userEvent.clear(term); + await userEvent.type(term, "Frame v2"); + expect(screen.getByDisplayValue("Frame v2")).toBeInTheDocument(); +}); + +it("seeds nothing when the template is blank", () => { + mockQueries({ + id: "builtin:blank", title: "Blank", description: "Empty.", builtin: true, + prefill: new TextEncoder().encode("slots: {}\n"), fieldRules: {}, + }); + renderAt("/frames/new?template=builtin:blank"); + expect(screen.getByLabelText(/frame name/i)).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: /^terminology$/i })).not.toBeInTheDocument(); +}); diff --git a/web/src/pages/FrameAuthoringPage.tsx b/web/src/pages/FrameAuthoringPage.tsx index 5a49244..1404908 100644 --- a/web/src/pages/FrameAuthoringPage.tsx +++ b/web/src/pages/FrameAuthoringPage.tsx @@ -9,6 +9,7 @@ import { FrameService } from "@gen/frames/v1/frame_service_pb"; import { authoringFormSchema, emptyFrameDoc, suggestNextVersion } from "@/lib/authoring-schema"; import { serializeFrameDoc, parseFrameContent } from "@/lib/frame-yaml"; import { SLOT_SECTIONS, sectionHasContent, type SlotSectionDef } from "@/lib/slot-sections"; +import { seededSections, isRequiredSection, sectionHint, type TemplateRules } from "@/lib/templates"; import { mapPublishError } from "@/lib/publish-errors"; import { type AuthoringForm, formToDoc, docToForm } from "@/components/form/form-model"; import { ExtendsEditor } from "@/components/form/ExtendsEditor"; @@ -38,29 +39,40 @@ const decode = (b: Uint8Array) => new TextDecoder().decode(b); // One editable section of the document: heading, the editor for its content // shape, and a remove control. Sections the author has not added simply are // not on the page - the document editor shows the document, not the schema. +// +// `hint` overrides `def.hint` when a template's rule carries a note for this +// slot, and `removable` is false for a template-required section: deleting +// one guarantees a publish failure, so the control is withheld rather than +// offered and then punished. function SectionEditor({ def, + hint, + removable, onRemove, }: { def: SlotSectionDef; + hint: string; + removable: boolean; onRemove: () => void; }) { return (

{def.label}

- + {removable && ( + + )}
-

{def.hint}

+

{hint}

{def.kind === "terms" && } {def.kind === "list" && ( @@ -100,6 +112,14 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) { const choosingTemplate = mode === "create" && !importing && templateID === ""; const templates = useQuery(FrameService.method.listFrameTemplates, {}); + // Fetched only once a template is chosen. Disabled otherwise so the picker + // screen does not issue a pointless request. + const chosen = useQuery( + FrameService.method.getFrameTemplate, + { id: templateID }, + { enabled: templateID !== "" }, + ); + const rules: TemplateRules = (chosen.data?.template?.fieldRules ?? {}) as TemplateRules; // Sections the author added this session; content-bearing sections are // always visible regardless (which covers the async edit-mode prefill). @@ -127,6 +147,44 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode, editQ.data?.version?.digest]); + // Seeds the form once the chosen template arrives. This mirrors the edit + // path exactly: getFrameTemplate -> parseFrameContent -> docToForm are the + // same three steps that open an existing Frame, so there is no second + // parsing path to keep in step. + const [seeded, setSeeded] = useState(false); + useEffect(() => { + if (mode !== "create" || templateID === "" || seeded) return; + const tmpl = chosen.data?.template; + if (!tmpl) return; + try { + // The prefill is the canonical YAML subset the backend produced, so it + // goes through the same parse the edit flow uses. + const doc = parseFrameContent(tmpl.prefill); + methods.reset(docToForm({ ...doc, version: "1.0.0" }, "")); + // Sections the template asks for are on the page from the start rather + // than behind "+ Add section": the point of a template is that the + // author does not have to know which sections this kind of Frame needs. + // Unioned with whatever the prefill itself populated, so prefilled + // content is never hidden behind a collapsed section. + const fromRules = seededSections(rules).map((def) => def.key); + const fromPrefill = SLOT_SECTIONS.filter((def) => sectionHasContent(def, doc.slots)).map( + (def) => def.key, + ); + // This is a callback reacting to an external system's data arriving + // (the template query resolving), which is the effect rule's own + // sanctioned use of setState-in-effect; the heuristic cannot tell that + // apart from deriving state from other state, hence the disable. + // eslint-disable-next-line react-hooks/set-state-in-effect + setAdded(new Set([...fromRules, ...fromPrefill])); + } catch { + setTimeout(() => setFormError("This template's starting content could not be loaded."), 0); + } + setSeeded(true); + // seed once per template choice; re-running on every `rules` identity + // change would fight the author's own edits after the initial seed + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chosen.data, mode, templateID, seeded]); + const slots = useWatch({ control: methods.control, name: "slots" }) as | AuthoringForm["slots"] | undefined; @@ -383,7 +441,13 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) { {visibleSections.map((def) => ( - removeSection(def)} /> + removeSection(def)} + /> ))}
From 93a8082a29642b0b1070a947b172129a95a01c0e Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:59:33 +0200 Subject: [PATCH 15/43] feat(web): publish with the chosen template and show its violations on the section The submit handler now sends templateId via publishTemplateID(), so the backend can check the chosen template's required slots on create. Doing so required switching the publish mutation from the callback-style mutate() to mutateAsync() so the request is a single object rather than a request plus an onSuccess/onError pair; publishFromMarkdown and convertFrame are untouched. No change was needed to render a required-slot violation inside its section: TerminologyEditor, ListEditor, and MarkdownField already each render a FieldError for their own slot path, and mapPublishError already keys fieldErrors by that same path, so the message already lands where the section is. Updated FrameAuthoringPage.create.test.tsx's mutation mock to match (mutateAsync instead of mutate, and a rejected promise instead of an onError callback) since the assertions it makes did not need to change. --- .../pages/FrameAuthoringPage.create.test.tsx | 8 ++- .../FrameAuthoringPage.template.test.tsx | 53 +++++++++++++++++++ web/src/pages/FrameAuthoringPage.tsx | 43 +++++++++------ 3 files changed, 86 insertions(+), 18 deletions(-) diff --git a/web/src/pages/FrameAuthoringPage.create.test.tsx b/web/src/pages/FrameAuthoringPage.create.test.tsx index 9bc561c..7e13050 100644 --- a/web/src/pages/FrameAuthoringPage.create.test.tsx +++ b/web/src/pages/FrameAuthoringPage.create.test.tsx @@ -12,10 +12,14 @@ vi.mock("react-router", async (orig) => ({ useNavigate: () => navigateMock, })); +// The submit handler awaits mutateAsync rather than the callback-style +// mutate(), so the mutation happens (or fails) on the promise this mock +// returns, and templateId lands in the single request object rather than a +// second onSuccess/onError argument. const mutateMock = vi.fn(); vi.mock("@connectrpc/connect-query", () => ({ useQuery: () => ({ data: { org: { slug: "openteams" } }, isLoading: false, error: null }), - useMutation: () => ({ mutate: mutateMock, isPending: false, isSuccess: false }), + useMutation: () => ({ mutateAsync: mutateMock, isPending: false, isSuccess: false }), createConnectQueryKey: () => ["k"], })); vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }) })); @@ -100,7 +104,7 @@ it("surfaces a server violation on the slot row that caused it", async () => { const err = new ConnectError("invalid", Code.InvalidArgument, undefined, [ { desc: FieldViolationsSchema, value: fv }, ]); - mutateMock.mockImplementation((_req, opts) => opts.onError(err)); + mutateMock.mockRejectedValue(err); renderCreate(); await fillIdentity(); diff --git a/web/src/pages/FrameAuthoringPage.template.test.tsx b/web/src/pages/FrameAuthoringPage.template.test.tsx index 6085a7e..a16e7fa 100644 --- a/web/src/pages/FrameAuthoringPage.template.test.tsx +++ b/web/src/pages/FrameAuthoringPage.template.test.tsx @@ -158,3 +158,56 @@ it("seeds nothing when the template is blank", () => { expect(screen.getByLabelText(/frame name/i)).toBeInTheDocument(); expect(screen.queryByRole("heading", { name: /^terminology$/i })).not.toBeInTheDocument(); }); + +it("sends the chosen template id when publishing", async () => { + const mutateAsync = vi.fn().mockResolvedValue({ frame: { name: "vocab" }, version: { version: "1.0.0" } }); + useMutationMock.mockReturnValue({ mutateAsync, isPending: false }); + mockQueries(vocabularyTemplate); + renderAt("/frames/new?template=builtin:domain-vocabulary"); + + await userEvent.type(screen.getByLabelText(/frame name/i), "vocab"); + await userEvent.type(screen.getByLabelText(/^description$/i), "Our terms"); + // Version and changelog live in the publish dialog, so a publish takes two + // clicks: open it, then confirm. The brief's snippet had only the first + // click, which opens the dialog but never submits - matching create.test.tsx's + // publishViaDialog() sequence here so the mutation actually fires. + await userEvent.click(screen.getByRole("button", { name: /publish…/i })); + await userEvent.click(await screen.findByRole("button", { name: /^publish$/i })); + + expect(mutateAsync).toHaveBeenCalled(); + const arg = mutateAsync.mock.calls[0][0]; + expect(arg.templateId).toBe("builtin:domain-vocabulary"); +}); + +// The import and edit paths sending no template id is covered by +// `publishTemplateID` in src/lib/templates.test.ts. It is asserted there rather +// than here because a test that only checks the payload "if a submit happened" +// passes when nothing happened at all, which proves nothing. The page's job is +// to call the helper; the helper's job is to be right. + +it("puts a required-slot violation under that section", async () => { + const { ConnectError, Code } = await import("@connectrpc/connect"); + const { FieldViolationsSchema } = await import("@gen/frames/v1/frame_service_pb"); + const { create } = await import("@bufbuild/protobuf"); + const detail = create(FieldViolationsSchema, { + violations: [{ field: "slots.terminology", message: 'required by the "Domain Vocabulary" template' }], + }); + const err = new ConnectError("invalid", Code.InvalidArgument, undefined, [ + { desc: FieldViolationsSchema, value: detail }, + ]); + useMutationMock.mockReturnValue({ mutateAsync: vi.fn().mockRejectedValue(err), isPending: false }); + mockQueries(vocabularyTemplate); + renderAt("/frames/new?template=builtin:domain-vocabulary"); + + await userEvent.type(screen.getByLabelText(/frame name/i), "vocab"); + await userEvent.type(screen.getByLabelText(/^description$/i), "Our terms"); + await userEvent.click(screen.getByRole("button", { name: /publish…/i })); + await userEvent.click(await screen.findByRole("button", { name: /^publish$/i })); + + const message = await screen.findByText(/required by the "Domain Vocabulary" template/i); + expect(message).toBeInTheDocument(); + // It has to be attached to the Terminology section, not floated to the top of + // the form: that placement is the whole point of the FieldViolations detail. + const section = screen.getByRole("heading", { name: /^terminology$/i }).closest("section"); + expect(section).toContainElement(message); +}); diff --git a/web/src/pages/FrameAuthoringPage.tsx b/web/src/pages/FrameAuthoringPage.tsx index 1404908..91382d3 100644 --- a/web/src/pages/FrameAuthoringPage.tsx +++ b/web/src/pages/FrameAuthoringPage.tsx @@ -9,7 +9,13 @@ import { FrameService } from "@gen/frames/v1/frame_service_pb"; import { authoringFormSchema, emptyFrameDoc, suggestNextVersion } from "@/lib/authoring-schema"; import { serializeFrameDoc, parseFrameContent } from "@/lib/frame-yaml"; import { SLOT_SECTIONS, sectionHasContent, type SlotSectionDef } from "@/lib/slot-sections"; -import { seededSections, isRequiredSection, sectionHint, type TemplateRules } from "@/lib/templates"; +import { + seededSections, + isRequiredSection, + sectionHint, + publishTemplateID, + type TemplateRules, +} from "@/lib/templates"; import { mapPublishError } from "@/lib/publish-errors"; import { type AuthoringForm, formToDoc, docToForm } from "@/components/form/form-model"; import { ExtendsEditor } from "@/components/form/ExtendsEditor"; @@ -297,23 +303,28 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) { // A publish failure closes the dialog only when the problem lives outside // it: a version conflict must be fixed where the version input is. - const onSubmit = (form: AuthoringForm) => { + const onSubmit = async (form: AuthoringForm) => { setFormError(null); const content = encode(serializeFrameDoc(formToDoc(form))); - publish.mutate( - { content, changelog: form.changelog }, - { - onSuccess: () => afterPublish(form.name), - onError: (err: unknown) => { - const { fieldErrors, formError: fe } = mapPublishError(err); - for (const [path, message] of Object.entries(fieldErrors)) { - methods.setError(path as never, { type: "server", message }); - } - setFormError(fe); - if (!fieldErrors.version) setPublishOpen(false); - }, - }, - ); + try { + await publish.mutateAsync({ + content, + changelog: form.changelog, + // publishTemplateID owns this rule (create only, and only when a + // template was actually chosen) and is unit-tested on its own; the + // page must call it rather than re-implement the condition inline, + // or the two can drift. + templateId: publishTemplateID({ mode, importing, templateID }), + }); + afterPublish(form.name); + } catch (err) { + const { fieldErrors, formError: fe } = mapPublishError(err); + for (const [path, message] of Object.entries(fieldErrors)) { + methods.setError(path as never, { type: "server", message }); + } + setFormError(fe); + if (!fieldErrors.version) setPublishOpen(false); + } }; // Invalid form on publish: keep the dialog open only when the version itself From a6b234ce247e5ef5cf4ee171d4af16357eb188f1 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:30:03 +0200 Subject: [PATCH 16/43] fix(web): send the chosen template id from the Markdown publish path too publishFromMarkdown published without templateId, so an author who chose a template and then switched to the Markdown editor before publishing had the template's required-slot check silently skipped, unlike publishing from the document editor. Both paths now compute the id the same way, so the check an author opted into no longer depends on which editor tab they used. --- .../FrameAuthoringPage.markdown.test.tsx | 40 +++++++++++++++++++ .../FrameAuthoringPage.template.test.tsx | 7 ++-- web/src/pages/FrameAuthoringPage.tsx | 8 +++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/web/src/pages/FrameAuthoringPage.markdown.test.tsx b/web/src/pages/FrameAuthoringPage.markdown.test.tsx index 3ba7e75..997315c 100644 --- a/web/src/pages/FrameAuthoringPage.markdown.test.tsx +++ b/web/src/pages/FrameAuthoringPage.markdown.test.tsx @@ -126,6 +126,46 @@ it("publishing from Markdown converts first, then publishes the canonical YAML", expect(new TextDecoder().decode(publishMock.mock.calls[0][0].content)).toBe(yaml); }); +it("carries the chosen template id through a Markdown-mode publish, and none when importing", async () => { + const yaml = "name: brand-voice\ndescription: d\nversion: 1.0.0\nvisibility: internal\nslots: {}\n"; + + // A template was chosen (renderCreate() lands on ?template=builtin:blank); + // switching to Markdown before publishing must not drop the check that + // choice opted into. Two conversions happen here: entering Markdown mode + // (doc -> markdown), then publish's own markdown -> yaml step. + convertMock + .mockImplementationOnce((_req, opts) => opts.onSuccess({ markdown: encode("---\n---\n") })) + .mockImplementationOnce((_req, opts) => opts.onSuccess({ yaml: encode(yaml) })); + + const templated = renderCreate(); + await openMarkdownMode(); + await screen.findByLabelText(/frame markdown source/i); + await userEvent.click(screen.getByRole("button", { name: /publish…/i })); + await userEvent.click(await screen.findByRole("button", { name: /^publish$/i })); + + await waitFor(() => expect(publishMock).toHaveBeenCalled()); + expect(publishMock.mock.calls[0][0].templateId).toBe("builtin:blank"); + templated.unmount(); + + // The ?import=1 path lands in Markdown mode with its source document already + // in hand, never a template: applying a standard the author never picked + // would be a surprise, same as the document-editor path. + publishMock.mockClear(); + convertMock.mockImplementationOnce((_req, opts) => opts.onSuccess({ yaml: encode(yaml) })); + + render( + + + , + ); + await screen.findByLabelText(/frame markdown source/i); + await userEvent.click(screen.getByRole("button", { name: /publish…/i })); + await userEvent.click(await screen.findByRole("button", { name: /^publish$/i })); + + await waitFor(() => expect(publishMock).toHaveBeenCalled()); + expect(publishMock.mock.calls[0][0].templateId).toBe(""); +}); + it("import mode opens straight into an empty Markdown editor", async () => { render( diff --git a/web/src/pages/FrameAuthoringPage.template.test.tsx b/web/src/pages/FrameAuthoringPage.template.test.tsx index a16e7fa..45dec2f 100644 --- a/web/src/pages/FrameAuthoringPage.template.test.tsx +++ b/web/src/pages/FrameAuthoringPage.template.test.tsx @@ -167,10 +167,9 @@ it("sends the chosen template id when publishing", async () => { await userEvent.type(screen.getByLabelText(/frame name/i), "vocab"); await userEvent.type(screen.getByLabelText(/^description$/i), "Our terms"); - // Version and changelog live in the publish dialog, so a publish takes two - // clicks: open it, then confirm. The brief's snippet had only the first - // click, which opens the dialog but never submits - matching create.test.tsx's - // publishViaDialog() sequence here so the mutation actually fires. + // The header button only opens the publish dialog; the dialog's own button + // is what actually submits, so clicking just the first would assert against + // a form that never published. await userEvent.click(screen.getByRole("button", { name: /publish…/i })); await userEvent.click(await screen.findByRole("button", { name: /^publish$/i })); diff --git a/web/src/pages/FrameAuthoringPage.tsx b/web/src/pages/FrameAuthoringPage.tsx index 91382d3..bc57d1b 100644 --- a/web/src/pages/FrameAuthoringPage.tsx +++ b/web/src/pages/FrameAuthoringPage.tsx @@ -345,7 +345,13 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) { { onSuccess: (res) => { publish.mutate( - { content: res.yaml, changelog: methods.getValues("changelog") }, + { + content: res.yaml, + changelog: methods.getValues("changelog"), + // Same rule as the document path: the check an author opted into + // must not depend on which editor tab they published from. + templateId: publishTemplateID({ mode, importing, templateID }), + }, { onSuccess: () => { let published = methods.getValues("name"); From e171aa36a70fbda71f1f97d631fc4bf38ee72b15 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:52:01 +0200 Subject: [PATCH 17/43] feat(web): manage organization Frame templates from the admin area Adds an admin page for creating, editing, and deleting an org's own Frame templates, plus the per-slot FieldRuleEditor it uses to set each content slot's requirement level and author note. Built-in templates get no edit or delete controls since the backend refuses both for them. Editing replaces the stored template wholesale, so the form always sends every field, and the prefill blob is restricted to slots and extends so no identity field can leak into it. --- web/src/app/routes.tsx | 2 + .../components/form/FieldRuleEditor.test.tsx | 39 ++ web/src/components/form/FieldRuleEditor.tsx | 46 ++ web/src/lib/frame-yaml.ts | 16 + web/src/pages/AdminHomePage.tsx | 1 + web/src/pages/AdminTemplatesPage.test.tsx | 118 +++++ web/src/pages/AdminTemplatesPage.tsx | 429 ++++++++++++++++++ 7 files changed, 651 insertions(+) create mode 100644 web/src/components/form/FieldRuleEditor.test.tsx create mode 100644 web/src/components/form/FieldRuleEditor.tsx create mode 100644 web/src/pages/AdminTemplatesPage.test.tsx create mode 100644 web/src/pages/AdminTemplatesPage.tsx diff --git a/web/src/app/routes.tsx b/web/src/app/routes.tsx index 1401fc3..d95f2d6 100644 --- a/web/src/app/routes.tsx +++ b/web/src/app/routes.tsx @@ -14,6 +14,7 @@ import { ConnectProviderPage } from "@/pages/ConnectProviderPage"; import { AdminHomePage } from "@/pages/AdminHomePage"; import { AdminMembersPage } from "@/pages/AdminMembersPage"; import { AdminFramesPage } from "@/pages/AdminFramesPage"; +import { AdminTemplatesPage } from "@/pages/AdminTemplatesPage"; export function AppRoutes() { return ( @@ -34,6 +35,7 @@ export function AppRoutes() { } /> } /> } /> + } /> diff --git a/web/src/components/form/FieldRuleEditor.test.tsx b/web/src/components/form/FieldRuleEditor.test.tsx new file mode 100644 index 0000000..c70f061 --- /dev/null +++ b/web/src/components/form/FieldRuleEditor.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm, FormProvider } from "react-hook-form"; +import { expect, it, vi } from "vitest"; +import { FieldRuleEditor } from "./FieldRuleEditor"; + +function Harness({ onValues }: { onValues: (v: unknown) => void }) { + const methods = useForm({ defaultValues: { rules: {} } }); + return ( + + + + + ); +} + +it("offers all three levels and defaults to optional", () => { + render(); + expect(screen.getByRole("radio", { name: /optional/i })).toBeChecked(); + expect(screen.getByRole("radio", { name: /recommended/i })).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: /required/i })).toBeInTheDocument(); +}); + +it("records the chosen level and the note", async () => { + const onValues = vi.fn(); + render(); + await userEvent.click(screen.getByRole("radio", { name: /required/i })); + await userEvent.type(screen.getByLabelText(/note/i), "One entry per term of art."); + await userEvent.click(screen.getByRole("button", { name: /dump/i })); + expect(onValues).toHaveBeenCalledWith( + expect.objectContaining({ + rules: expect.objectContaining({ + terminology: expect.objectContaining({ note: "One entry per term of art." }), + }), + }), + ); +}); diff --git a/web/src/components/form/FieldRuleEditor.tsx b/web/src/components/form/FieldRuleEditor.tsx new file mode 100644 index 0000000..03c26db --- /dev/null +++ b/web/src/components/form/FieldRuleEditor.tsx @@ -0,0 +1,46 @@ +import { useFormContext } from "react-hook-form"; +import { Requirement } from "@gen/frames/v1/frame_pb"; + +// One slot's rule: how strongly the template wants it, and what belongs there. +// The note matters as much as the level - it is what the seeded authoring form +// shows in place of the generic hint, and what an MCP client asks as a question. +const LEVELS: { value: Requirement; label: string; help: string }[] = [ + { value: Requirement.OPTIONAL, label: "Optional", help: "Not offered up front." }, + { value: Requirement.RECOMMENDED, label: "Recommended", help: "Shown, but may be left empty." }, + { value: Requirement.REQUIRED, label: "Required", help: "A new Frame cannot be published without it." }, +]; + +export function FieldRuleEditor({ slotKey, label }: { slotKey: string; label: string }) { + const { register, watch, setValue } = useFormContext(); + const level = (watch(`rules.${slotKey}.level`) as Requirement) ?? Requirement.OPTIONAL; + return ( +
+

{label}

+
+ {LEVELS.map((opt) => ( + + ))} +
+ +
+ ); +} diff --git a/web/src/lib/frame-yaml.ts b/web/src/lib/frame-yaml.ts index 551ab55..03a0f55 100644 --- a/web/src/lib/frame-yaml.ts +++ b/web/src/lib/frame-yaml.ts @@ -82,3 +82,19 @@ export function serializeFrameDoc(doc: FrameDoc): string { out.slots = compactSlots(doc.slots); return stringifyYaml(out); } + +// Serializes a template prefill: the same slot compaction as serializeFrameDoc, +// but the input type itself has no room for name/description/version - a +// template seeds content, never identity, and backend/internal/frames/templates.go +// ParsePrefill rejects a blob that sets any of them. Mirrors that package's own +// prefillDoc, which for the same reason omits those keys from its encoder +// rather than emitting them empty and relying on the decoder to catch it. +export function serializeFramePrefill(input: { + slots: FrameDoc["slots"]; + extends?: FrameDoc["extends"]; +}): string { + const out: Record = {}; + if (input.extends && input.extends.length > 0) out.extends = input.extends; + out.slots = compactSlots(input.slots); + return stringifyYaml(out); +} diff --git a/web/src/pages/AdminHomePage.tsx b/web/src/pages/AdminHomePage.tsx index ef63b8f..8188d9a 100644 --- a/web/src/pages/AdminHomePage.tsx +++ b/web/src/pages/AdminHomePage.tsx @@ -12,6 +12,7 @@ export function AdminHomePage() {
  • Manage members
  • Manage frames
  • +
  • Manage templates
); diff --git a/web/src/pages/AdminTemplatesPage.test.tsx b/web/src/pages/AdminTemplatesPage.test.tsx new file mode 100644 index 0000000..9664e3c --- /dev/null +++ b/web/src/pages/AdminTemplatesPage.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { expect, it, vi, beforeEach } from "vitest"; +// The mock factory below routes by RPC descriptor identity, which means it +// references FrameService by name. Import bindings are resolved before any +// module-level code runs (unlike the mock-prefixed const workaround genuinely +// local variables need), so this is safe to reference inside vi.mock's factory. +import { FrameService } from "@gen/frames/v1/frame_service_pb"; + +const useQueryMock = vi.fn(); +const createMock = vi.fn(); +const updateMock = vi.fn(); +const deleteMock = vi.fn(); +// Routed by RPC descriptor OBJECT IDENTITY, not by call order and not by a name +// string. A modulo counter over the call sequence silently binds the wrong mock +// as soon as the component adds a mutation or re-renders, and the test then +// asserts against the wrong spy while still passing. A name string fails +// differently but just as quietly: DescMethod.name is the proto-declared +// PascalCase name ("CreateFrameTemplate"), so a lowerCamelCase comparison never +// matches and every mutation falls through to the default. +vi.mock("@connectrpc/connect-query", () => ({ + useQuery: (...args: unknown[]) => useQueryMock(...args), + useMutation: (method: unknown) => { + if (method === FrameService.method.createFrameTemplate) { + return { mutateAsync: createMock, isPending: false }; + } + if (method === FrameService.method.updateFrameTemplate) { + return { mutateAsync: updateMock, isPending: false }; + } + if (method === FrameService.method.deleteFrameTemplate) { + return { mutateAsync: deleteMock, isPending: false }; + } + return { mutateAsync: vi.fn(), isPending: false }; + }, + createConnectQueryKey: () => ["k"], +})); +vi.mock("@tanstack/react-query", async (importOriginal) => ({ + ...(await importOriginal()), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { AdminTemplatesPage } from "./AdminTemplatesPage"; + +beforeEach(() => { + createMock.mockResolvedValue({ template: { id: "new" } }); + updateMock.mockResolvedValue({ template: { id: "t1" } }); + deleteMock.mockResolvedValue({}); + useQueryMock.mockReturnValue({ + isLoading: false, + error: null, + data: { + canManage: true, + templates: [ + { id: "builtin:blank", title: "Blank", description: "Empty.", builtin: true }, + { id: "t1", title: "Our House Style", description: "Ours.", builtin: false }, + ], + }, + }); +}); + +const renderPage = () => render(); + +it("lists the org's templates and the built-ins separately", () => { + renderPage(); + expect(screen.getByText("Our House Style")).toBeInTheDocument(); + expect(screen.getByText("Blank")).toBeInTheDocument(); +}); + +it("offers edit and delete on an org template but not on a built-in", () => { + renderPage(); + const orgRow = screen.getByText("Our House Style").closest("li, tr")!; + const builtinRow = screen.getByText("Blank").closest("li, tr")!; + expect(orgRow.querySelector('[data-testid="edit-template"]')).toBeTruthy(); + expect(orgRow.querySelector('[data-testid="delete-template"]')).toBeTruthy(); + // A built-in is compiled into the binary. Offering controls that can only + // fail would be a lie about what the page can do. + expect(builtinRow.querySelector('[data-testid="edit-template"]')).toBeFalsy(); + expect(builtinRow.querySelector('[data-testid="delete-template"]')).toBeFalsy(); +}); + +it("creates a template with a title, a description, and a rule", async () => { + renderPage(); + await userEvent.click(screen.getByRole("button", { name: /new template/i })); + await userEvent.type(screen.getByLabelText(/^title$/i), "Vocabulary"); + await userEvent.type(screen.getByLabelText(/^description$/i), "Our terms"); + await userEvent.click(screen.getAllByRole("radio", { name: /required/i })[0]); + await userEvent.click(screen.getByRole("button", { name: /^create$/i })); + await waitFor(() => expect(createMock).toHaveBeenCalled()); + const arg = createMock.mock.calls[0][0]; + expect(arg.title).toBe("Vocabulary"); + expect(arg.description).toBe("Our terms"); + expect(Object.keys(arg.fieldRules ?? {}).length).toBeGreaterThan(0); +}); + +it("asks before deleting", async () => { + renderPage(); + const orgRow = screen.getByText("Our House Style").closest("li, tr")!; + await userEvent.click(orgRow.querySelector('[data-testid="delete-template"]') as HTMLElement); + // Deleting a template cannot break an existing Frame, but it is still + // destructive to the org's standard, so it is confirmed. + expect(screen.getByText(/frames already created from it are unaffected/i)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); + await waitFor(() => expect(deleteMock).toHaveBeenCalledWith({ id: "t1" })); +}); + +it("surfaces a duplicate-title error on the title field", async () => { + const { ConnectError, Code } = await import("@connectrpc/connect"); + createMock.mockRejectedValue( + new ConnectError('a template titled "Vocabulary" already exists in this organization', Code.AlreadyExists), + ); + renderPage(); + await userEvent.click(screen.getByRole("button", { name: /new template/i })); + await userEvent.type(screen.getByLabelText(/^title$/i), "Vocabulary"); + await userEvent.type(screen.getByLabelText(/^description$/i), "Our terms"); + await userEvent.click(screen.getByRole("button", { name: /^create$/i })); + expect(await screen.findByText(/already exists in this organization/i)).toBeInTheDocument(); +}); diff --git a/web/src/pages/AdminTemplatesPage.tsx b/web/src/pages/AdminTemplatesPage.tsx new file mode 100644 index 0000000..b42834e --- /dev/null +++ b/web/src/pages/AdminTemplatesPage.tsx @@ -0,0 +1,429 @@ +import { useEffect, useState } from "react"; +import { useForm, FormProvider, useFormContext } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useQuery, useMutation, createConnectQueryKey } from "@connectrpc/connect-query"; +import { useQueryClient } from "@tanstack/react-query"; +import { ConnectError, Code } from "@connectrpc/connect"; +import { FrameService } from "@gen/frames/v1/frame_service_pb"; +import { Requirement } from "@gen/frames/v1/frame_pb"; +import type { FrameTemplateSummary } from "@gen/frames/v1/frame_pb"; +import { Plus, Pencil, Trash2 } from "lucide-react"; +import { SLOT_SECTIONS, type SlotSectionDef } from "@/lib/slot-sections"; +import { parseFrameContent, serializeFramePrefill, type FrameDoc } from "@/lib/frame-yaml"; +import { TerminologyEditor } from "@/components/form/TerminologyEditor"; +import { ListEditor } from "@/components/form/ListEditor"; +import { MarkdownField } from "@/components/form/MarkdownField"; +import { FieldRuleEditor } from "@/components/form/FieldRuleEditor"; +import { FieldError, useFieldError, errorProps } from "@/components/form/FieldError"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Alert } from "@/components/ui/alert"; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle } from "@/components/ui/dialog"; + +const encode = (s: string) => new TextEncoder().encode(s); + +// Content-only schema for a template's prefill: no name/description/version - +// duplicated from frame-yaml.ts's slotsSchema rather than reused, matching the +// precedent authoring-schema.ts already sets of keeping the strict authoring +// shape separate from the loose round-trip one. +const termSchema = z.object({ term: z.string(), definition: z.string() }); +const slotsSchema = z.object({ + terminology: z.array(termSchema).optional(), + rules: z.array(z.string()).optional(), + skills: z.array(z.string()).optional(), + prompts: z.array(z.string()).optional(), + tool_specs: z.string().optional(), + goals: z.string().optional(), + style: z.string().optional(), + norms: z.string().optional(), + architecture: z.string().optional(), + business_process: z.string().optional(), +}); +const ruleEntrySchema = z.object({ + level: z.nativeEnum(Requirement).optional(), + note: z.string().optional(), +}); +const templateFormSchema = z.object({ + title: z.string().trim().min(1, "must not be empty"), + description: z.string().trim().min(1, "must not be empty"), + slots: slotsSchema, + rules: z.record(z.string(), ruleEntrySchema).optional(), +}); +type TemplateForm = z.infer; + +function emptyTemplateForm(): TemplateForm { + return { title: "", description: "", slots: {}, rules: {} }; +} + +// Only a slot whose admin actually set something is worth persisting: an +// OPTIONAL level with no note is indistinguishable from the slot never having +// been touched, so field_rules stays as small as what the admin meant. +function buildFieldRules(rules: TemplateForm["rules"]): Record { + const out: Record = {}; + for (const def of SLOT_SECTIONS) { + const rule = rules?.[def.key]; + const level = rule?.level ?? Requirement.OPTIONAL; + const note = rule?.note?.trim() ?? ""; + if (level === Requirement.OPTIONAL && note === "") continue; + out[def.key] = { level, note }; + } + return out; +} + +// Title and description live inside the FormProvider tree (not in the parent +// that creates it) so useFieldError/useFormContext resolve correctly - a +// component cannot consume a context it itself provides. +function TemplateIdentityFields() { + const { register } = useFormContext(); + const titleError = useFieldError("title"); + const descriptionError = useFieldError("description"); + return ( +
+ +