diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f16ba31..ad062cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -269,14 +269,38 @@ jobs: # Prove it from inside the cluster. Without this the failure surfaces # 15 minutes later as an unexplained unhealthy Application, which is # how the upstream no-op went unnoticed in the first place. - kubectl delete pod dns-probe --ignore-not-found >/dev/null - OUT=$(kubectl run dns-probe --image=busybox:1.36 --restart=Never \ - --attach --rm --quiet --image-pull-policy=IfNotPresent \ - --command -- nslookup "keycloak.${DOMAIN}" 2>&1) || true - echo "${OUT}" - echo "${OUT}" | grep -qF "${GATEWAY_IP}" \ - || { echo "::error::keycloak.${DOMAIN} does not resolve to ${GATEWAY_IP} from inside the cluster"; exit 1; } - echo "OK: keycloak.${DOMAIN} resolves to ${GATEWAY_IP} for pods" + # + # Wait rather than sampling once. `rollout status` reports the new + # CoreDNS pods Ready before the new Corefile is necessarily live in + # them: a ConfigMap mounted as a volume propagates on kubelet's sync + # interval, and CoreDNS reloads on a poll of its own. A single probe + # fired straight after the rollout therefore loses a race roughly half + # the time, and a lost race is indistinguishable from the real + # misconfiguration this check exists to catch. Retrying to a deadline + # makes slow propagation read as slow and leaves only genuine breakage + # reading as broken. + RESOLVED=0 + for attempt in $(seq 1 24); do + kubectl delete pod dns-probe --ignore-not-found >/dev/null 2>&1 + OUT=$(kubectl run dns-probe --image=busybox:1.36 --restart=Never \ + --attach --rm --quiet --image-pull-policy=IfNotPresent \ + --command -- nslookup "keycloak.${DOMAIN}" 2>&1) || true + if echo "${OUT}" | grep -qF "${GATEWAY_IP}"; then + RESOLVED=1 + echo "OK: keycloak.${DOMAIN} resolves to ${GATEWAY_IP} for pods (attempt ${attempt})" + break + fi + echo "[${attempt}/24] keycloak.${DOMAIN} not resolving yet" + sleep 5 + done + if [ "${RESOLVED}" != "1" ]; then + echo "::error::keycloak.${DOMAIN} does not resolve to ${GATEWAY_IP} from inside the cluster after 24 attempts" + echo "last probe output:" + echo "${OUT}" + echo "Corefile the ConfigMap currently holds:" + kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' || true + exit 1 + fi - name: Provision the sandbox CA for the frames pod env: @@ -479,3 +503,231 @@ jobs: 200|302|303|307) echo "endpoint OK" ;; *) echo "::error::unexpected HTTP ${CODE}"; exit 1 ;; esac + + - name: Resolve the gateway host names for the runner + env: + GATEWAY_IP: ${{ steps.sandbox.outputs.gateway-ip }} + CA_CERT_PATH: ${{ steps.sandbox.outputs.ca-cert-path }} + run: | + # Both the token-acquisition step and the blackbox suite below talk + # to frames.nebari.local and keycloak.nebari.local from the runner + # itself rather than from inside the cluster. curl can fake that with + # --resolve per call, but Go's http.Client has no equivalent, and the + # generated Connect client needs a real base URL it can dial - so a + # genuine hosts entry is the only thing that works for both. + echo "${GATEWAY_IP} frames.nebari.local keycloak.nebari.local" | sudo tee -a /etc/hosts + + # Exported via GITHUB_ENV so every later step inherits it: the gateway + # presents the sandbox's self-signed CA, and pointing SSL_CERT_FILE at + # it is what lets both curl and the Go suite verify the connection + # for real instead of skipping verification. + echo "SSL_CERT_FILE=${CA_CERT_PATH}" >> "$GITHUB_ENV" + + - name: Provision an e2e Keycloak identity + id: token + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: | + REALM=nebari + KC_BASE=https://keycloak.nebari.local + # --cacert rather than the SSL_CERT_FILE the Go suite relies on below: + # Go's crypto/x509 documents honoring that env var on Linux, but curl's + # pickup of it depends on how the runner's curl was built against + # OpenSSL, which is not a bet worth making for a suite whose whole + # point is verifying the real certificate. + CACERT="${SSL_CERT_FILE}" + + # The operator authenticates to Keycloak's admin API with these same + # credentials to provision every NebariApp's OIDC client (see + # nebari-operator's KeycloakConfig / KEYCLOAK_ADMIN_SECRET_NAME, + # default "keycloak-admin-credentials" in the "keycloak" namespace). + # Reusing them here means this step fails the same way the operator's + # own provisioning would if the admin API ever rejected them, rather + # than depending on a credential nothing else exercises. + KC_ADMIN_USER=$(kubectl -n keycloak get secret keycloak-admin-credentials -o jsonpath='{.data.admin-username}' | base64 -d) + KC_ADMIN_PASS=$(kubectl -n keycloak get secret keycloak-admin-credentials -o jsonpath='{.data.admin-password}' | base64 -d) + + ADMIN_TOKEN_RESPONSE=$(curl -s --cacert "${CACERT}" -X POST "${KC_BASE}/realms/master/protocol/openid-connect/token" \ + -d grant_type=password -d client_id=admin-cli \ + -d "username=${KC_ADMIN_USER}" -d "password=${KC_ADMIN_PASS}") + ADMIN_TOKEN=$(echo "${ADMIN_TOKEN_RESPONSE}" | jq -r '.access_token') + [ -n "${ADMIN_TOKEN}" ] && [ "${ADMIN_TOKEN}" != "null" ] \ + || { echo "::error::could not obtain a Keycloak master admin token: ${ADMIN_TOKEN_RESPONSE}"; exit 1; } + + # The backend validates the token's aud claim against OIDC_CLIENT_ID, + # which the chart wires to the operator-provisioned SPA client, not + # the confidential client naming.ClientID() would suggest (see + # docs/qa/e2e-token-acquisition.md, and backend/internal/auth/validator.go's + # oidc.Config{ClientID: cfg.ClientID}). Reading it out of the secret + # the operator actually wrote, rather than re-deriving the "--spa" + # naming convention here, means a convention change breaks this loudly + # at the read below instead of silently at token validation in CI. + SPA_CLIENT_ID=$(kubectl -n nebari-frames get secret nebari-frames-nebari-frames-oidc-client \ + -o jsonpath='{.data.spa-client-id}' | base64 -d) + [ -n "${SPA_CLIENT_ID}" ] \ + || { echo "::error::spa-client-id is empty in the operator-provisioned OIDC secret"; exit 1; } + + # A purpose-provisioned client: the SPA client the operator provisions + # has directAccessGrantsEnabled=false (it exists for the browser's PKCE + # flow, not password grants), so automated tests need their own. The + # audience mapper is the detail that actually matters here - without + # it the token's aud is this client's own id, which the backend + # rejects; see docs/qa/e2e-token-acquisition.md for how that was found. + CREATE_CLIENT_CODE=$(curl -s --cacert "${CACERT}" -o /tmp/create-client.json -w '%{http_code}' \ + -X POST "${KC_BASE}/admin/realms/${REALM}/clients" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg aud "${SPA_CLIENT_ID}" '{ + clientId: "frames-e2e-test", + enabled: true, + publicClient: true, + directAccessGrantsEnabled: true, + standardFlowEnabled: false, + protocolMappers: [{ + name: "frames-e2e-audience", + protocol: "openid-connect", + protocolMapper: "oidc-audience-mapper", + config: { + "included.client.audience": $aud, + "id.token.claim": "false", + "access.token.claim": "true" + } + }] + }')") + [ "${CREATE_CLIENT_CODE}" = "201" ] \ + || { echo "::error::client provisioning failed (HTTP ${CREATE_CLIENT_CODE})"; cat /tmp/create-client.json; exit 1; } + + # e2e/templates_test.go asserts can_manage for the seeded admin, and + # frames grants that by matching a user's email against SEED_ADMIN_EMAIL + # at first login (see backend/internal/seed), not by anything Keycloak + # itself knows about - the Application spec below sets that email to + # e2e@example.com. The realm's built-in "admin" user has a different + # email and would only get frames' fail-closed default (no + # membership, PermissionDenied), so this also needs its own user. + E2E_PASSWORD=$(openssl rand -hex 24) + echo "::add-mask::${E2E_PASSWORD}" + + # firstName and lastName are not decoration. Keycloak's declarative + # user profile is on by default and marks both required, so a user + # created without them is left incomplete and the password grant + # refuses with "Account is not fully set up" even though the account + # exists and the password was accepted. requiredActions is cleared + # explicitly for the same reason: a realm default such as + # UPDATE_PASSWORD would block the grant the same way. + CREATE_USER_CODE=$(curl -s --cacert "${CACERT}" -o /tmp/create-user.json -w '%{http_code}' \ + -X POST "${KC_BASE}/admin/realms/${REALM}/users" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" -H 'Content-Type: application/json' \ + -d '{"username":"frames-e2e","email":"e2e@example.com","emailVerified":true,"enabled":true,"firstName":"Frames","lastName":"E2E","requiredActions":[]}') + [ "${CREATE_USER_CODE}" = "201" ] \ + || { echo "::error::user provisioning failed (HTTP ${CREATE_USER_CODE})"; cat /tmp/create-user.json; exit 1; } + + USER_ID=$(curl -s --cacert "${CACERT}" "${KC_BASE}/admin/realms/${REALM}/users?username=frames-e2e&exact=true" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" | jq -r '.[0].id') + [ -n "${USER_ID}" ] && [ "${USER_ID}" != "null" ] \ + || { echo "::error::could not look up the id of the just-created frames-e2e user"; exit 1; } + + SET_PASSWORD_CODE=$(curl -s --cacert "${CACERT}" -o /dev/null -w '%{http_code}' \ + -X PUT "${KC_BASE}/admin/realms/${REALM}/users/${USER_ID}/reset-password" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg pw "${E2E_PASSWORD}" '{type: "password", value: $pw, temporary: false}')") + [ "${SET_PASSWORD_CODE}" = "204" ] \ + || { echo "::error::setting the e2e user's password failed (HTTP ${SET_PASSWORD_CODE})"; exit 1; } + + TOKEN_RESPONSE=$(curl -s --cacert "${CACERT}" -X POST "${KC_BASE}/realms/${REALM}/protocol/openid-connect/token" \ + -d grant_type=password -d client_id=frames-e2e-test \ + -d username=frames-e2e -d "password=${E2E_PASSWORD}") + ACCESS_TOKEN=$(echo "${TOKEN_RESPONSE}" | jq -r '.access_token') + [ -n "${ACCESS_TOKEN}" ] && [ "${ACCESS_TOKEN}" != "null" ] \ + || { + echo "::error::password grant failed: ${TOKEN_RESPONSE}" + # "Account is not fully set up" says nothing about WHICH condition + # is unmet, and the account is gone once the cluster is torn down. + # Dump what the server thinks is outstanding so the next failure + # is diagnosable from the log alone. + echo "user record as Keycloak sees it:" + curl -s --cacert "${CACERT}" "${KC_BASE}/admin/realms/${REALM}/users/${USER_ID}" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + | jq '{enabled, emailVerified, requiredActions, firstName, lastName}' + exit 1 + } + + echo "::add-mask::${ACCESS_TOKEN}" + echo "access_token=${ACCESS_TOKEN}" >> "$GITHUB_OUTPUT" + + - name: Blackbox RPC suite + env: + FRAMES_E2E_TOKEN: ${{ steps.token.outputs.access_token }} + run: | + export FRAMES_E2E_BASE_URL=https://frames.nebari.local + # Without this guard a failed acquisition step above would leave + # FRAMES_E2E_TOKEN empty, every test would skip via t.Skipf, and the + # job would go green having asserted nothing - precisely the failure + # the "Auth is really on" step exists to prevent for dev mode, and + # the same trap applies here. + [ -n "${FRAMES_E2E_TOKEN}" ] || { echo "::error::no token; the acquisition step failed"; exit 1; } + + # Take the serving certificate from the cluster rather than from the + # path the sandbox action reports, which is a different certificate + # altogether: pinning against it failed with a presented-versus-pinned + # mismatch. curl never noticed, because OpenSSL anchors on a + # self-signed certificate by exact match and the provisioning calls + # above talk to a different host. + # + # The subtlety is which secret. The nebari-operator gives each + # NebariApp its own Gateway listener with its own certificate, whose + # only SAN is that app's hostname, and Envoy serves it for that SNI. + # nebari-gateway-tls is merely the catch-all listener's certificate + # (SANs: apex, keycloak, argocd) and is NOT what frames.nebari.local + # presents - pinning it is what broke this job before. So resolve the + # certificate from the listener matching the hostname under test + # instead of naming a secret, which also survives a rename of the + # operator's "--tls" convention. + # + # Reading it over the authenticated kubeconfig means the expected + # identity is learned from a trusted channel, not from the connection + # being tested, which trusting-on-first-use would amount to. + GW_NS=$(kubectl get gateway -A \ + -o jsonpath='{.items[?(@.metadata.name=="nebari-gateway")].metadata.namespace}') + [ -n "${GW_NS}" ] || { + echo "::error::no nebari-gateway Gateway in any namespace" + kubectl get gateway -A 2>/dev/null || true + exit 1 + } + CERT_SECRET=$(kubectl -n "${GW_NS}" get gateway nebari-gateway \ + -o jsonpath='{.spec.listeners[?(@.hostname=="frames.nebari.local")].tls.certificateRefs[0].name}') + [ -n "${CERT_SECRET}" ] || { + echo "::error::no Gateway listener for frames.nebari.local, so the operator never provisioned its certificate" + kubectl -n "${GW_NS}" get gateway nebari-gateway -o yaml || true + exit 1 + } + echo "pinning ${GW_NS}/${CERT_SECRET} (listener for frames.nebari.local)" + kubectl -n "${GW_NS}" get secret "${CERT_SECRET}" \ + -o jsonpath='{.data.tls\.crt}' | base64 -d > /tmp/gateway-tls.crt + export FRAMES_E2E_CA_CERT=/tmp/gateway-tls.crt + + # Fingerprints, so any future mismatch is explicable at a glance + # instead of costing another sandbox run to characterise. The last two + # must agree; the first is expected to differ. + echo "certificate fingerprints:" + printf ' action-reported path: %s\n' \ + "$(openssl x509 -in "${SSL_CERT_FILE}" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2)" + printf ' %s/%s tls.crt (pinned): %s\n' "${GW_NS}" "${CERT_SECRET}" \ + "$(openssl x509 -in /tmp/gateway-tls.crt -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2)" + printf ' presented by the gateway: %s\n' \ + "$(openssl s_client -connect frames.nebari.local:443 -servername frames.nebari.local \ + /dev/null | openssl x509 -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2)" + + # What the trust anchor actually is, printed before the suite runs. + # "certificate signed by unknown authority" names no cause, and the + # cluster is gone by the time anyone reads the log, so a repeat would + # otherwise cost another full sandbox run to explain. Expect a + # self-signed leaf (CA:FALSE) whose only SAN is the hostname under + # test; the harness pins that rather than chaining to it. + echo "trust anchor at ${FRAMES_E2E_CA_CERT}:" + openssl x509 -in "${FRAMES_E2E_CA_CERT}" -noout -subject -issuer -dates \ + -ext basicConstraints,keyUsage,subjectAltName 2>&1 || true + echo "chain the gateway actually presents:" + openssl s_client -connect frames.nebari.local:443 -servername frames.nebari.local \ + -showcerts /dev/null \ + | grep -E "^ *[0-9]+ s:|^ *i:|Verify return code" || true + + go test -tags e2e -count=1 -v ./e2e/... diff --git a/AGENTS.md b/AGENTS.md index b6982b6..d8cc868 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ make dev # backend :8080 (dev mode, fixture-seeded) + Vite :5173 with make dev-auth # Keycloak in Docker :8081 + backend on :5173 serving the built SPA make dev-clean # kill orphan dev servers, drop dev DB + WAL/SHM, tear down Keycloak make image # docker build (linux/amd64) +make e2e # blackbox RPC suite against a deployed frames (needs FRAMES_E2E_BASE_URL + FRAMES_E2E_TOKEN) # One Go test / package go test ./backend/internal/frames -run TestResolve -race @@ -45,8 +46,11 @@ CI (`.github/workflows/ci.yml`) gates on five jobs: `proto` (buf lint plus a sta `gen/`), `go` (golangci-lint pinned to v2.12 plus race tests), `web` (lint, typecheck, vitest), `chart` (helm lint, template renders, kubeconform pinned to v0.7.0), and `e2e-sandbox` (deploys the built image onto a kind Nebari sandbox via ArgoCD and exercises real Keycloak auth through the -gateway). `docs.yml` also fails if the generated CLI reference is stale. Run the local -equivalents before pushing. +gateway). `e2e-sandbox` also provisions a dedicated Keycloak client and user, then runs the +build-tagged blackbox RPC suite in `e2e/` (`make e2e`) against the deployed pod through the real +gateway; that suite skips locally whenever `FRAMES_E2E_BASE_URL` and `FRAMES_E2E_TOKEN` are unset, so +`make test` is unaffected. `docs.yml` also fails if the generated CLI reference is stale. Run the +local equivalents before pushing. **Generated code is checked in.** After touching `proto/frames/v1/*.proto`, run `make proto` and commit `gen/`. After touching `cli/cmd/*`, run `go run ./tools/docs-gen` and commit @@ -85,6 +89,22 @@ store.Repository**. - `slots.go` - `SlotTable` is the single source of truth for slot keys, markdown headings, and content shape (terms / list / prose). Add or rename a slot here only; the `.frame.md` codec and the MCP composer both read it. + - `templates.go` / `builtins.go` - Frame templates: an authoring affordance, not Frames and not + a Frame Spec concept. A template carries prefill content plus a per-slot rule (required / + recommended / optional, with a note). Built-ins are embedded YAML parsed and validated at + package init, so a malformed starter panics at startup rather than failing at a user's first + click. `Check` reports required slots a document leaves empty; `publish` merges those with the + schema's own violations so an author sees everything at once. Checked on create only: nothing + is recorded on the Frame, so `required` is an authoring aid rather than ongoing governance. + See `docs/adr/0001-frame-templates-are-not-frames.md`. A prefill is held to the same content + rules a Frame is (`contentErrors`), but at the write boundary (`validateTemplateInput`), never + in `ParsePrefill`: that decoder is also the read path (`rowToTemplate` runs every stored row + through it), so a rule enforced there would make an already-stored row unreadable rather than + merely unpublishable. Starters arrive through two doors - the write RPCs and `loadBuiltins` at + package init. Built-ins carry rules and notes but no content at all: `builtinFile` has no + `prefill` key, so there is nothing for a content rule to check there. + `TestBuiltinFileCarriesNoPrefill` is a tripwire over that premise - add such a field and it + fails, telling you to run `contentErrors` in `loadBuiltins` and to replace the tripwire. - `framemd.go` - the Frame Spec v0.2 `.frame.md` codec (YAML frontmatter plus one `##` section per slot). Round-trip fidelity matters: `examples/*.yaml` and `examples/*.frame.md` are checked-in conformance fixtures asserted by `examples_test.go`. @@ -102,7 +122,10 @@ store.Repository**. cannot be shadowed by a case variant. The in-memory fake enforces the same unique constraints; where it cannot, tests reach for real SQLite and say why. Publishes go through `CreateFrameVersion`, which inserts the frame row, version, inheritance edges, and grants - atomically. **SQLite is single-writer, so the deployment is pinned to one replica.** + atomically. + `frame_templates` holds org-authored templates, with prefill and field rules as opaque blobs so + a change to `SlotTable` never touches the schema; built-ins are not rows. + **SQLite is single-writer, so the deployment is pinned to one replica.** - `backend/internal/mcp` is a thin protocol adapter over `frames.Service`, exposing frames as MCP resources under `nebari-frame:///[@]` plus RFC 9728 metadata at `/.well-known/oauth-protected-resource`. It is also a write surface: `create_frame` and @@ -122,6 +145,33 @@ store.Repository**. `//go:embed` still compiles on a clean checkout. Auth guards live in `web/src/app/` (`RequireAuth`, `RequireMembership`, `RequireAdmin`); pages in `web/src/pages/`; RPC transport and domain helpers in `web/src/lib/`. + - Seeding a form from server data comes in two shapes. A form that may re-seed keys its `reset` + on an identity that changes when the data does (the authoring page's edit path keys on + `version.digest`). A form that seeds once and then belongs to the author - a template prefill, + the template edit dialog - must pin `refetchOnMount: "always"` on the query and take its data + from `freshData()` (`web/src/lib/query-freshness.ts`), which is where the reasoning lives: + React Query returns a cached row synchronously and refetches behind it, counts a *failed* + fetch in `isFetchedAfterMount`, and never clears cached data on error. Where the latch lives + depends on where the form's identity comes from: the template edit dialog is keyed and mounted + per target, so a boolean is its identity, while the authoring page's template comes from + `?template=` and can change while the page stays mounted, so it latches on the id it seeded + from. + - A form seeded from a query does not render editable before the seed lands. Both template + surfaces withhold it (a skeleton, or the dialog's), because `reset` replaces form state + wholesale and `refetchOnMount: "always"` guarantees the seed arrives a round trip after the + first render - so anything typed in between would vanish. + - `react-hooks/set-state-in-effect` reports at most one violation per effect, so a second + `setState` in the same effect can look clean while being the same construct. Do not read the + linter's silence as a constraint, and do not add a `setTimeout` claiming to satisfy a rule + that never fired - an unused `eslint-disable` is itself reported. + - Two schemas cover the same slot keys on purpose. `frame-yaml.ts` decodes whatever is stored + (lenient, because rows predate rules); `contentSlotsSchema` in `authoring-schema.ts` is what a + human may submit, and every form that edits slot content resolves against it. The backend + splits the same two jobs the same way (`Parse` vs `contentErrors`). + - Error text splits by direction. A failed read states a fixed sentence - the server's message + there is storage or wiring detail and retrying is all the reader can do. A failed write shows + `ConnectError.rawMessage`, because it names what the caller must change; when it carries + `FieldViolations`, it belongs on the input instead. - `cli/` is the `frames` binary (Cobra plus Viper; config at `~/.config/frames/config.yaml`, env prefix `FRAMES_`, device-flow login). - `chart/` deploys onto a Nebari cluster; the nebari-operator provisions routing, TLS, and the OIDC @@ -148,5 +198,5 @@ store.Repository**. and keep new comments in the same register. - Always stop a dev loop with a single Ctrl-C. Killing it leaves the SQLite lock held and the next start fails with `disk I/O error` / `database is locked`; `make dev-clean` recovers. -- Design docs in `docs/design/`, client connection guides in `docs/connect/`, manual QA scripts in - `docs/qa/`. +- Design docs in `docs/design/`, architecture decision records in `docs/adr/`, client connection + guides in `docs/connect/`, manual QA scripts in `docs/qa/`. diff --git a/Makefile b/Makefile index fc1fa6c..bcb785c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: proto lint test build build-web run-dev dev dev-auth dev-clean clean image +.PHONY: proto lint test e2e build build-web run-dev dev dev-auth dev-clean clean image # Local dev runner: builds the SPA into the binary, then starts the server in # dev mode (no OIDC). The auth interceptor injects a fixed dev-user identity; @@ -16,10 +16,20 @@ proto: lint: golangci-lint run ./... + # e2e is built with a `//go:build e2e` tag so it stays out of `go build ./...` + # and out of a plain lint run; without this second invocation nothing ever + # lints it and it can silently rot. + golangci-lint run --build-tags e2e ./e2e/... test: go test ./... -race -coverprofile=coverage.out +# Blackbox suite against a deployed frames. Needs FRAMES_E2E_BASE_URL and +# FRAMES_E2E_TOKEN; skips loudly without them. Not part of `make test`, which +# must stay runnable with no cluster. +e2e: + go test -tags e2e -count=1 -v ./e2e/... + build: CGO_ENABLED=0 go build -o nebari-frames-server ./backend/cmd/server 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..d98a876 --- /dev/null +++ b/backend/internal/frames/builtins_test.go @@ -0,0 +1,133 @@ +package frames + +import ( + "reflect" + "strings" + "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) + } + } +} + +// Built-ins are the other door a starter can arrive through, and they skip the +// write RPCs entirely - so the content rules a prefill is held to +// (validateTemplateInput) would not apply to one shipped in a builtin file. +// That is safe only because builtinFile has no prefill key: built-ins carry +// rules and notes, never content. This asserts that premise structurally +// rather than trusting a comment, in the same idiom internal/mcp uses to pin +// its input structs to the slot table. +// +// If this fails, you added a prefill to built-in templates. Run contentErrors +// over it in loadBuiltins before appending, so a malformed starter fails at +// startup rather than at an author's first click +// (docs/adr/0001-frame-templates-are-not-frames.md). +func TestBuiltinFileCarriesNoPrefill(t *testing.T) { + ft := reflect.TypeOf(builtinFile{}) + for i := range ft.NumField() { + field := ft.Field(i) + tag, name := field.Tag.Get("yaml"), strings.ToLower(field.Name) + if strings.Contains(tag, "prefill") || strings.Contains(name, "prefill") { + t.Fatalf("builtinFile now has a prefill field (%s); loadBuiltins must validate it", field.Name) + } + } +} 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..81eaa03 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 @@ -147,21 +175,33 @@ 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. +// AlreadyExists (with a field violation naming "name" when the name is taken, +// and none when a version is republished), 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 for a caller that only has a decoded Doc, not the +// author's original bytes: it re-marshals doc into canonical form itself, +// which loses whatever comments and formatting the author's own bytes had. +// Only PublishFrame (the Connect RPC handler) preserves those exactly, because +// it passes req.Msg.Content straight into publish instead of going through +// here. A future caller that needs the author's bytes to survive - the CLI +// included - must route through PublishFrame, not this function. +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 @@ -170,16 +210,43 @@ func (s *Service) PublishDocFrom(ctx context.Context, doc *Doc, changelog string if err != nil { return nil, nil, connect.NewError(connect.CodeInternal, 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,10 +268,10 @@ 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: - return nil, nil, connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("a frame named %q already exists; update it instead", doc.Name)) + case !isNew && req.Intent == PublishCreate: + return nil, nil, nameTakenErr(doc.Name) } now := timestamppb.Now() @@ -223,10 +290,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 +328,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, } @@ -660,6 +727,24 @@ func violationErr(err error) *connect.Error { return cerr } +// nameTakenErr reports a create-intent publish onto a name that already exists. +// +// The detail matters as much as the code here: a republished version is also +// AlreadyExists, so a client that has only the code and the message text cannot +// tell the two apart, and the one input the author must change differs between +// them. Naming the field lets a client put the message where the fix is. +func nameTakenErr(name string) *connect.Error { + msg := fmt.Sprintf("a frame named %q already exists; update it instead", name) + cerr := connect.NewError(connect.CodeAlreadyExists, errors.New(msg)) + fv := &framesv1.FieldViolations{ + Violations: []*framesv1.FieldViolation{{Field: "name", Message: msg}}, + } + if detail, derr := connect.NewErrorDetail(fv); derr == nil { + cerr.AddDetail(detail) + } + return cerr +} + // ConvertFrame translates between the canonical slot YAML and the .frame.md // interchange format defined by Frame Spec v0.2. It is a pure function of its // input - it touches no storage - so it requires only that the caller is a diff --git a/backend/internal/frames/service_test.go b/backend/internal/frames/service_test.go index 3152d93..a5daab5 100644 --- a/backend/internal/frames/service_test.go +++ b/backend/internal/frames/service_test.go @@ -1025,3 +1025,83 @@ func TestService_NonAdvancingVersionIsAFieldViolation(t *testing.T) { t.Errorf("no field violation on 'version'; details = %v", ce.Details()) } } + +// violationFields collects the field paths from every FieldViolations detail on +// err. Returned as a set because callers only ever ask whether a field was +// named, and the order details arrive in is not part of the contract. +func violationFields(t *testing.T, err error) map[string]bool { + t.Helper() + var ce *connect.Error + if !errors.As(err, &ce) { + t.Fatalf("want *connect.Error, got %T (%v)", err, err) + } + got := map[string]bool{} + for _, d := range ce.Details() { + msg, derr := d.Value() + if derr != nil { + continue + } + if fv, ok := msg.(*framesv1.FieldViolations); ok { + for _, v := range fv.Violations { + got[v.Field] = true + } + } + } + return got +} + +// A taken frame name and a republished version both come back as AlreadyExists, +// and a client cannot tell them apart from the code alone. Only the first is a +// problem with an input the author can see, so it names that input: without the +// detail the web app has nothing but message text to route by, and it put every +// AlreadyExists under the version field - where changing the version retried +// straight back into the same error. +func TestService_PublishDocAlreadyExistsNamesTheOffendingField(t *testing.T) { + tests := []struct { + name string + version string + intent frames.PublishIntent + wantFields []string + }{ + { + name: "a name that is already taken is reported on name", + version: "2.0.0", + intent: frames.PublishCreate, + wantFields: []string{"name"}, + }, + { + // The version input is where this one is fixed, and the web app + // already defaults there, so no detail is needed - but it must not + // arrive claiming the name is at fault. + name: "a republished version names no field", + version: "1.0.0", + intent: frames.PublishUpdate, + wantFields: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := store.NewMemory() + ctx := seedOrg(t, repo, "caller", "admin") + svc := frames.NewService(repo) + if _, _, err := svc.PublishDoc(ctx, docFor("brand-voice", "1.0.0", "seeded"), "seed", frames.PublishCreate); err != nil { + t.Fatalf("seed publish: %v", err) + } + + _, _, err := svc.PublishDoc(ctx, docFor("brand-voice", tt.version, "a rule"), "", tt.intent) + if connect.CodeOf(err) != connect.CodeAlreadyExists { + t.Fatalf("code = %v (err %v), want AlreadyExists", connect.CodeOf(err), err) + } + got := violationFields(t, err) + if len(got) != len(tt.wantFields) { + t.Fatalf("violation fields = %v, want %v", got, tt.wantFields) + } + for _, want := range tt.wantFields { + if !got[want] { + t.Errorf("no violation for %q; got %v", want, got) + } + } + }) + } +} diff --git a/backend/internal/frames/templates.go b/backend/internal/frames/templates.go new file mode 100644 index 0000000..d62bbe3 --- /dev/null +++ b/backend/internal/frames/templates.go @@ -0,0 +1,303 @@ +package frames + +import ( + "bytes" + "encoding/json" + "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 +} + +// 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 []ExtendRef `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") + } + // Content is deliberately NOT checked here. This is the read path as much + // as the write one (rowToTemplate decodes every stored row through it), so + // refusing a blank list row here would make a template already saved with + // one unreadable - GetFrameTemplate and the post-save read would both fail + // - rather than merely unpublishable. The check belongs at the write + // boundary, and lives in validateTemplateInput. + 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) { + 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. +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_service.go b/backend/internal/frames/templates_service.go new file mode 100644 index 0000000..2831481 --- /dev/null +++ b/backend/internal/frames/templates_service.go @@ -0,0 +1,363 @@ +package frames + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "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. 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: + 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. + // + // This order is the contract `frames template list` presents and the one + // this API guarantees; it is not what a screen has to follow. The web + // picker (TemplatePicker) renders the org's group above built-ins, because + // grouping by origin reads better there - that choice does not make this + // order stale, it just means the CLI is this ordering's real consumer. + 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 +} + +// GetFrameTemplate returns one template the caller may use. A response is +// never successful and empty: either an error, or a Template. The web app's +// edit dialog relies on that - it treats "no row" as still loading, so a +// success carrying nothing would leave it on a loading skeleton forever rather +// than reporting anything. TestGetFrameTemplate pins it on every id that +// succeeds. +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 +} + +// 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 + } +} + +// 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 +} + +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 { + decodedPrefill, err := ParsePrefill(prefill) + if err != nil { + return templateInput{}, connect.NewError(connect.CodeInvalidArgument, err) + } + // A starter that cannot be published is not a starter: these bytes are + // spliced verbatim into `frames template init`'s scaffold and into the + // authoring form, so a blank list row saved here becomes a publish + // failure - "slots.rules[0]: must not be empty" - in a file some other + // author was handed as ready to fill in. Held to the same content rules + // a Frame is, at the boundary where the bytes arrive. + if errs := contentErrors(&decodedPrefill.Slots, decodedPrefill.Extends); len(errs) > 0 { + // Through violationErr like every other ValidationError that crosses + // this boundary (service.go), so the field paths contentErrors just + // produced reach the client as a detail rather than only as a + // flattened message. A client that maps violations onto inputs can + // then point at the offending row. + return templateInput{}, violationErr(&ValidationError{Errors: errs}) + } + } + 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 new file mode 100644 index 0000000..df13e8d --- /dev/null +++ b/backend/internal/frames/templates_service_test.go @@ -0,0 +1,591 @@ +package frames + +import ( + "context" + "errors" + "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 + // A success must carry a template. The web edit dialog reads "no + // row" as "still fetching", so an empty success would strand it on + // its loading skeleton with nothing to report - which is why that + // dialog has no defensive branch for it. + if got == nil { + t.Fatal("success carried no 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) + } +} + +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") + } +} + +// A prefill is spliced verbatim into `frames template init`'s scaffold and into +// the authoring form, so content no publish would accept has to be refused +// where the template is saved. Otherwise the failure lands on a different +// author, at their first publish, naming a row they never wrote. +func TestTemplateWritesRefusePrefillContentThatCannotBePublished(t *testing.T) { + tests := []struct { + name string + prefill string + wantMsg string + wantField string + }{ + { + name: "a blank list row", + prefill: "slots:\n rules:\n - \"\"\n", + wantMsg: "slots.rules[0]: must not be empty", + wantField: "slots.rules[0]", + }, + { + name: "a terminology entry missing its definition", + prefill: "slots:\n terminology:\n - term: Frame\n definition: \"\"\n", + wantMsg: "slots.terminology[0].definition: must not be empty", + wantField: "slots.terminology[0].definition", + }, + { + name: "an unpinned parent", + prefill: "extends:\n - ref: acme/base\n version: \"\"\nslots: {}\n", + wantMsg: "extends[0].version: must be pinned to a version", + wantField: "extends[0].version", + }, + { + name: "content that publishes cleanly is accepted", + prefill: "slots:\n rules:\n - Never claim numbers without data\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create and update share validateTemplateInput, and both are + // checked: an update replaces the stored row wholesale, so a rule + // enforced only on create would be a rule an update can walk past. + f, ctx := newTemplateFixture(t, "admin") + seedOrgTemplate(t, f.repo, "t1", "org-a", "Our House Style", nil) + + for _, call := range []struct { + name string + run func() error + }{ + {"create", func() error { + _, err := f.svc.CreateFrameTemplate(ctx, connect.NewRequest(&framesv1.CreateFrameTemplateRequest{ + Title: "New One", Description: "D", Prefill: []byte(tt.prefill), + })) + return err + }}, + {"update", func() error { + _, err := f.svc.UpdateFrameTemplate(ctx, connect.NewRequest(&framesv1.UpdateFrameTemplateRequest{ + Id: "t1", Title: "Renamed", Description: "D", Prefill: []byte(tt.prefill), + })) + return err + }}, + } { + err := call.run() + if tt.wantMsg == "" { + if err != nil { + t.Errorf("%s: unexpected error: %v", call.name, err) + } + continue + } + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("%s: code = %v (err %v), want InvalidArgument", call.name, connect.CodeOf(err), err) + } + if !strings.Contains(err.Error(), tt.wantMsg) { + t.Errorf("%s: err = %v, want it to name %q", call.name, err, tt.wantMsg) + } + // The path must also arrive as a FieldViolations detail, the way + // a publish's violations do: a message a client has to substring + // its way through cannot be attached to an input. + var ce *connect.Error + if !errors.As(err, &ce) { + t.Fatalf("%s: not a connect error: %v", call.name, err) + } + found := false + for _, d := range ce.Details() { + msg, derr := d.Value() + if derr != nil { + continue + } + fv, ok := msg.(*framesv1.FieldViolations) + if !ok { + continue + } + for _, v := range fv.Violations { + if v.Field == tt.wantField { + found = true + } + } + } + if !found { + t.Errorf("%s: no field violation for %q on %v", call.name, tt.wantField, err) + } + } + }) + } +} diff --git a/backend/internal/frames/templates_test.go b/backend/internal/frames/templates_test.go new file mode 100644 index 0000000..e52d70a --- /dev/null +++ b/backend/internal/frames/templates_test.go @@ -0,0 +1,324 @@ +package frames + +import ( + "regexp" + "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"}, + // Content is not judged here, on purpose. This decoder is the read path + // too (rowToTemplate runs every stored row through it), so refusing a + // blank row would make a template already saved with one unreadable + // instead of merely unpublishable. The write boundary refuses it - + // TestTemplateWritesRefusePrefillContentThatCannotBePublished. + { + name: "a blank list row still decodes, so a stored row stays readable", + yaml: "slots:\n rules:\n - \"\"\n terminology:\n - term: Frame\n definition: \"\"\n", + wantTerm: "Frame", + }, + } + 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. + // + // 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 { + 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) + } +} + +// 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) + } + }) + } +} diff --git a/backend/internal/frames/validate.go b/backend/internal/frames/validate.go index b06d3db..287c067 100644 --- a/backend/internal/frames/validate.go +++ b/backend/internal/frames/validate.go @@ -65,8 +65,29 @@ func Validate(doc *Doc) error { add("visibility", "must be one of "+strings.Join(VisibilityValues, ", ")) } + errs = append(errs, contentErrors(&doc.Slots, doc.Extends)...) + + if len(errs) > 0 { + return &ValidationError{Errors: errs} + } + return nil +} + +// contentErrors checks the part of a document that is not identity: the ten +// slots and the parents it declares. +// +// Split out of Validate so a template prefill can be held to the same rules. +// A prefill is exactly this part of a document - it must not carry identity at +// all (see ParsePrefill) - and it is spliced verbatim into an author's +// scaffold, so content that no publish would accept has to be refused where the +// template is saved (validateTemplateInput) rather than where a Frame is +// published from it. +func contentErrors(slots *Slots, extends []ExtendRef) []FieldError { + var errs []FieldError + add := func(path, msg string) { errs = append(errs, FieldError{Path: path, Message: msg}) } + seenTerm := map[string]bool{} - for i, term := range doc.Slots.Terminology { + for i, term := range slots.Terminology { if strings.TrimSpace(term.Term) == "" { add(fmt.Sprintf("slots.terminology[%d].term", i), "must not be empty") } else if seenTerm[term.Term] { @@ -85,11 +106,11 @@ func Validate(doc *Doc) error { } } } - checkList("rules", doc.Slots.Rules) - checkList("skills", doc.Slots.Skills) - checkList("prompts", doc.Slots.Prompts) + checkList("rules", slots.Rules) + checkList("skills", slots.Skills) + checkList("prompts", slots.Prompts) - for i, e := range doc.Extends { + for i, e := range extends { if !strings.Contains(e.Ref, "/") { add(fmt.Sprintf("extends[%d].ref", i), "must be org_slug/frame_name") } @@ -97,9 +118,5 @@ func Validate(doc *Doc) error { add(fmt.Sprintf("extends[%d].version", i), "must be pinned to a version") } } - - if len(errs) > 0 { - return &ValidationError{Errors: errs} - } - return nil + return errs } 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..7357945 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,218 @@ 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. + // + // 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() + const wantPrefill = "slots: {}\n" + const wantRules = `{"style":{"level":"required"}}` + input := &store.FrameTemplate{ + ID: "t1", OrgID: "org-a", Title: "Brand Voice", + 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) + } + 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 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) + } +} 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) + } +} 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) } diff --git a/cli/cmd/auth_test.go b/cli/cmd/auth_test.go index 35ee12b..977f5f7 100644 --- a/cli/cmd/auth_test.go +++ b/cli/cmd/auth_test.go @@ -55,3 +55,17 @@ func runCmd(t *testing.T, apiURL string, args ...string) string { } return buf.String() } + +// runCmdErr is runCmd for cases that must fail: it returns the error rather than +// failing the test, so a rejection can be asserted on. +func runCmdErr(t *testing.T, apiURL string, args ...string) (string, error) { + t.Helper() + c := NewRootCmd() + var buf bytes.Buffer + c.SetOut(&buf) + c.SetErr(&buf) + full := append([]string{"--api-url", apiURL, "--credentials-path", filepath.Join(t.TempDir(), "c.json")}, args...) + c.SetArgs(full) + err := c.Execute() + return buf.String(), err +} diff --git a/cli/cmd/publish.go b/cli/cmd/publish.go index 246e8e8..123111b 100644 --- a/cli/cmd/publish.go +++ b/cli/cmd/publish.go @@ -10,7 +10,7 @@ import ( ) func addPublishCmd(root *cobra.Command) { - var dir, changelog string + var dir, changelog, templateID string cmd := &cobra.Command{ Use: "publish", Short: "Publish a Frame from a directory containing frame.yaml", @@ -26,10 +26,18 @@ func addPublishCmd(root *cobra.Command) { } return err } - frame, version, err := getClientCtx(cmd.Context()).Publish(cmd.Context(), content, changelog) + frame, version, err := getClientCtx(cmd.Context()).Publish(cmd.Context(), content, changelog, templateID) if err != nil { - if connect.CodeOf(err) == connect.CodeInvalidArgument { + switch code := connect.CodeOf(err); { + case code == connect.CodeInvalidArgument: return fmt.Errorf("frame.yaml is invalid: %w", err) + case code == connect.CodeAlreadyExists && templateID != "": + // --template asserts a new Frame, which is exactly what a + // second publish is not. The scaffold's header names the + // flag, so an author following it lands here with nothing + // in the server's message pointing at the flag. + return fmt.Errorf( + "%w (--template applies to a Frame's first version only; publish later versions without it)", err) } return authAware(err) } @@ -39,5 +47,7 @@ func addPublishCmd(root *cobra.Command) { } cmd.Flags().StringVar(&dir, "dir", "", "Directory containing frame.yaml") cmd.Flags().StringVar(&changelog, "changelog", "", "Release notes for this version") + cmd.Flags().StringVar(&templateID, "template", "", + "Template this Frame is being created from; asserts the Frame is new and checks the template's required sections") root.AddCommand(cmd) } diff --git a/cli/cmd/publish_test.go b/cli/cmd/publish_test.go index 7c9b4c2..49a23a9 100644 --- a/cli/cmd/publish_test.go +++ b/cli/cmd/publish_test.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "context" + "errors" "os" "path/filepath" "strings" @@ -64,3 +65,100 @@ func TestPublish(t *testing.T) { }) } } + +// recordTemplateID captures the template_id the CLI actually sent, which is the +// whole point of the publish tests: the flag has to reach the wire. +func recordTemplateID(into *string) func(context.Context, *connect.Request[framesv1.PublishFrameRequest]) (*connect.Response[framesv1.PublishFrameResponse], error) { + return func(_ context.Context, r *connect.Request[framesv1.PublishFrameRequest]) (*connect.Response[framesv1.PublishFrameResponse], error) { + *into = r.Msg.TemplateId + return connect.NewResponse(&framesv1.PublishFrameResponse{ + Frame: &framesv1.Frame{Name: "vocab"}, + Version: &framesv1.FrameVersion{Version: "1.0.0"}, + }), nil + } +} + +func TestPublishPassesTheTemplateID(t *testing.T) { + var got string + url := testutil.NewStubServer(t, &testutil.StubService{PublishFn: recordTemplateID(&got)}) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "frame.yaml"), + []byte("name: vocab\ndescription: Our terms\nversion: 1.0.0\nslots: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runCmdErr(t, url, "publish", "--dir", dir, "--template", "builtin:domain-vocabulary"); err != nil { + t.Fatalf("publish: %v", err) + } + if got != "builtin:domain-vocabulary" { + t.Errorf("server saw template_id %q, want the flag's value", got) + } +} + +func TestPublishWithoutTemplateSendsNothing(t *testing.T) { + var got string + url := testutil.NewStubServer(t, &testutil.StubService{PublishFn: recordTemplateID(&got)}) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "frame.yaml"), + []byte("name: vocab\ndescription: Our terms\nversion: 1.0.0\nslots: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runCmdErr(t, url, "publish", "--dir", dir); err != nil { + t.Fatalf("publish: %v", err) + } + if got != "" { + t.Errorf("server saw template_id %q, want empty", got) + } +} + +// A second publish of a scaffolded Frame is the likeliest way to meet this +// error: the scaffold's header names --template, the flag asserts a new Frame, +// and the bare "already_exists" the server returns says nothing about the flag +// that caused it. +func TestPublishAlreadyExistsPointsAtTheTemplateFlag(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + wantAbsent string + }{ + { + name: "with --template, the flag is named as the cause", + args: []string{"--template", "builtin:domain-vocabulary"}, + wantErr: "--template", + }, + { + name: "without it, no flag is blamed", + args: nil, + // Suggesting a flag the author never passed would send them + // looking for a command they did not run. + wantErr: "already", + wantAbsent: "--template", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := testutil.NewStubServer(t, &testutil.StubService{ + PublishFn: func(context.Context, *connect.Request[framesv1.PublishFrameRequest]) (*connect.Response[framesv1.PublishFrameResponse], error) { + return nil, connect.NewError(connect.CodeAlreadyExists, + errors.New(`a frame named "vocab" already exists; update it instead`)) + }, + }) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "frame.yaml"), + []byte("name: vocab\ndescription: Our terms\nversion: 2.0.0\nslots: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + args := append([]string{"publish", "--dir", dir}, tt.args...) + _, err := runCmdErr(t, url, args...) + if err == nil { + t.Fatal("publish succeeded against a server that refused it") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error %q does not mention %q", err, tt.wantErr) + } + if tt.wantAbsent != "" && strings.Contains(err.Error(), tt.wantAbsent) { + t.Errorf("error %q should not mention %q", err, tt.wantAbsent) + } + }) + } +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 6f9fc7c..4fe1fe7 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -47,6 +47,7 @@ and print the inheritance-resolved form of a Frame.`, addShowCmd(rootCmd) addExtendsCmd(rootCmd) addResolveCmd(rootCmd) + addTemplateCmd(rootCmd) return rootCmd } diff --git a/cli/cmd/template.go b/cli/cmd/template.go new file mode 100644 index 0000000..15343c4 --- /dev/null +++ b/cli/cmd/template.go @@ -0,0 +1,174 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + + framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1" + "github.com/spf13/cobra" +) + +func addTemplateCmd(root *cobra.Command) { + templateCmd := &cobra.Command{ + Use: "template", + Short: "Browse Frame templates and scaffold a Frame from one", + } + addTemplateListCmd(templateCmd) + addTemplateInitCmd(templateCmd) + root.AddCommand(templateCmd) +} + +func addTemplateListCmd(parent *cobra.Command) { + cmd := &cobra.Command{ + Use: "list", + Short: "List the Frame templates you can start from", + RunE: func(cmd *cobra.Command, _ []string) error { + templates, _, err := getClientCtx(cmd.Context()).ListTemplates(cmd.Context()) + if err != nil { + return authAware(err) + } + if len(templates) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No templates found.") + return nil + } + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tTITLE\tSOURCE\tDESCRIPTION") + for _, t := range templates { + // "built-in" vs "organization" rather than a bare boolean: the + // distinction an author cares about is who controls it. + source := "organization" + if t.Builtin { + source = "built-in" + } + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", t.Id, t.Title, source, t.Description) + } + return w.Flush() + }, + } + parent.AddCommand(cmd) +} + +// scaffoldComment renders the guidance block at the top of a scaffold. It names +// the template and lists what it wants, so an author filling the file in does +// not have to go back to the web app to find out. +// +// These comments persist into the published content, because the Connect RPC +// stores the author's exact bytes. That is accepted: they document why the file +// is shaped the way it is, and the alternative - printing guidance to the +// terminal and handing over a bare file - loses it the moment the terminal +// scrolls. +func scaffoldComment(t *framesv1.FrameTemplate) string { + var b strings.Builder + fmt.Fprintf(&b, "# Scaffolded from the %q template.\n", t.Title) + if t.Description != "" { + fmt.Fprintf(&b, "# %s\n", t.Description) + } + // Both commands, because --template asserts a new Frame: it is what makes + // the requirement check run, and the server refuses it once the Frame + // exists. Naming only the first would leave an author following a header + // that stops working - permanently, since these lines persist into the + // published content (docs/adr/0001-frame-templates-are-not-frames.md). Kept + // to two lines for the same reason. + b.WriteString("#\n# Fill in name, description, and version, then publish:\n") + b.WriteString("# frames publish --dir . --template " + t.Id + " # the first version\n") + b.WriteString("# frames publish --dir . # every version after\n") + + // Required first: those are what will actually refuse the publish. Slot + // keys are sorted rather than walked in schema order, because that order + // lives in the backend's frame schema package, which a CLI binary cannot + // import (see scaffoldBody's comment below); alphabetical is still + // deterministic, which is what a test - and an author rereading the file + // on a second run - actually needs. + for _, want := range []framesv1.Requirement{ + framesv1.Requirement_REQUIREMENT_REQUIRED, + framesv1.Requirement_REQUIREMENT_RECOMMENDED, + } { + keys := make([]string, 0, len(t.FieldRules)) + for key, rule := range t.FieldRules { + if rule.Level == want { + keys = append(keys, key) + } + } + sort.Strings(keys) + var lines []string + for _, key := range keys { + rule := t.FieldRules[key] + line := fmt.Sprintf("# %s (%s)", key, strings.ToLower(strings.TrimPrefix(want.String(), "REQUIREMENT_"))) + if rule.Note != "" { + line += ": " + rule.Note + } + lines = append(lines, line) + } + if len(lines) > 0 { + b.WriteString("#\n") + b.WriteString(strings.Join(lines, "\n") + "\n") + } + } + return b.String() +} + +// scaffoldBody assembles frame.yaml's full contents: the guidance comment, +// empty identity keys for the author to fill in, and the template's prefill +// verbatim. +// +// It does not parse or re-marshal the prefill. The server only ever sends the +// "extends"/"slots" subset (GetFrameTemplate always goes through +// MarshalPrefill, which structurally omits every identity field), so splicing +// it in after the identity keys produces exactly the document a decode-then- +// encode round trip would, without needing a decoder here at all. +func scaffoldBody(t *framesv1.FrameTemplate) []byte { + var b strings.Builder + b.WriteString(scaffoldComment(t)) + // name and description have no sensible default, so they are left for the + // author to type; version gets a real starting value, since "leave it + // blank" is not a meaningful answer for a field that is otherwise always a + // semver string. + b.WriteString("name: \"\"\n") + b.WriteString("description: \"\"\n") + b.WriteString("version: \"1.0.0\"\n") + b.Write(t.Prefill) + return []byte(b.String()) +} + +func addTemplateInitCmd(parent *cobra.Command) { + var templateID, dir string + var force bool + cmd := &cobra.Command{ + Use: "init", + Short: "Write a frame.yaml scaffold from a template, ready to fill in and publish", + RunE: func(cmd *cobra.Command, _ []string) error { + if templateID == "" { + return fmt.Errorf("--template is required; run `frames template list` to see the options") + } + if dir == "" { + dir = "." + } + tmpl, err := getClientCtx(cmd.Context()).GetTemplate(cmd.Context(), templateID) + if err != nil { + return authAware(err) + } + path := filepath.Join(dir, "frame.yaml") + if _, statErr := os.Stat(path); statErr == nil && !force { + // Overwriting someone's in-progress Frame is not recoverable, so + // it takes an explicit flag. + return fmt.Errorf("%s already exists; pass --force to overwrite it", path) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if err := os.WriteFile(path, scaffoldBody(tmpl), 0o644); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Wrote %s from %q. Fill in name and description, then publish.\n", path, tmpl.Title) + return nil + }, + } + cmd.Flags().StringVar(&templateID, "template", "", "Template id from frames template list") + cmd.Flags().StringVar(&dir, "dir", ".", "Directory to write frame.yaml into") + cmd.Flags().BoolVar(&force, "force", false, "Overwrite an existing frame.yaml") + parent.AddCommand(cmd) +} diff --git a/cli/cmd/template_test.go b/cli/cmd/template_test.go new file mode 100644 index 0000000..8a403cc --- /dev/null +++ b/cli/cmd/template_test.go @@ -0,0 +1,271 @@ +package cmd + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "connectrpc.com/connect" + "github.com/nebari-dev/nebari-frames/cli/internal/testutil" + framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1" + "gopkg.in/yaml.v3" +) + +func TestTemplateListCommand(t *testing.T) { + tests := []struct { + name string + templates []*framesv1.FrameTemplateSummary + wantLines []string + wantAbsent []string + }{ + { + name: "shows built-ins and org templates with their source", + templates: []*framesv1.FrameTemplateSummary{ + {Id: "builtin:blank", Title: "Blank", Description: "Empty.", Builtin: true}, + {Id: "01JORG", Title: "Our House Style", Description: "Ours.", Builtin: false}, + }, + wantLines: []string{"builtin:blank", "Blank", "built-in", "01JORG", "Our House Style", "organization"}, + }, + { + name: "says so when there are none", + templates: nil, + wantLines: []string{"No templates"}, + wantAbsent: []string{"ID"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := testutil.NewStubServer(t, &testutil.StubService{ + ListTemplatesFn: func(_ context.Context, _ *connect.Request[framesv1.ListFrameTemplatesRequest]) (*connect.Response[framesv1.ListFrameTemplatesResponse], error) { + return connect.NewResponse(&framesv1.ListFrameTemplatesResponse{Templates: tt.templates}), nil + }, + }) + out := runCmd(t, url, "template", "list") + for _, want := range tt.wantLines { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + for _, absent := range tt.wantAbsent { + if strings.Contains(out, absent) { + t.Errorf("output should not contain %q:\n%s", absent, out) + } + } + }) + } +} + +// returns serves a fixed template from GetFrameTemplate. +func returns(t *framesv1.FrameTemplate) func(context.Context, *connect.Request[framesv1.GetFrameTemplateRequest]) (*connect.Response[framesv1.GetFrameTemplateResponse], error) { + return func(context.Context, *connect.Request[framesv1.GetFrameTemplateRequest]) (*connect.Response[framesv1.GetFrameTemplateResponse], error) { + return connect.NewResponse(&framesv1.GetFrameTemplateResponse{Template: t}), nil + } +} + +func TestTemplateInitWritesAScaffold(t *testing.T) { + tmpl := &framesv1.FrameTemplate{ + Id: "builtin:domain-vocabulary", Title: "Domain Vocabulary", + Description: "Terms.", Builtin: true, + Prefill: []byte("slots:\n terminology:\n - term: Frame\n definition: A scoped context artifact.\n"), + FieldRules: map[string]*framesv1.FieldRule{ + "terminology": {Level: framesv1.Requirement_REQUIREMENT_REQUIRED, Note: "One entry per term of art."}, + "rules": {Level: framesv1.Requirement_REQUIREMENT_RECOMMENDED, Note: "Usage constraints."}, + }, + } + url := testutil.NewStubServer(t, &testutil.StubService{GetTemplateFn: returns(tmpl)}) + dir := t.TempDir() + + out := runCmd(t, url, "template", "init", "--template", "builtin:domain-vocabulary", "--dir", dir) + if !strings.Contains(out, "frame.yaml") { + t.Errorf("output does not name the file it wrote:\n%s", out) + } + + b, err := os.ReadFile(filepath.Join(dir, "frame.yaml")) + if err != nil { + t.Fatalf("reading the scaffold: %v", err) + } + got := string(b) + for _, want := range []string{ + "Domain Vocabulary", // the header names the template + "One entry per term of art.", // the required rule's note + "Usage constraints.", // the recommended rule's note + "required", // the level is stated + "term: Frame", // the prefill content is present + } { + if !strings.Contains(got, want) { + t.Errorf("scaffold missing %q:\n%s", want, got) + } + } + // It has to be publishable as-is once identity is filled in, so it must + // carry the keys the schema requires. + for _, want := range []string{"name:", "description:", "version:"} { + if !strings.Contains(got, want) { + t.Errorf("scaffold missing the %q key:\n%s", want, got) + } + } +} + +// TestTemplateInitScaffoldIsOneWellFormedDocument decodes the written scaffold +// instead of substring-matching it. scaffoldBody trades a guarantee enforced +// by construction (build a Doc, marshal it) for one that is merely trusted: +// that the server's prefill bytes stay shaped as promised (no identity keys, +// no document separator). If a prefill ever emitted a "---" document-start +// marker, a YAML decoder reading the assembled scaffold would return only the +// first document - the three identity lines this command writes - and every +// slot and extend the template seeded would silently vanish. A substring +// check would not notice; only decoding does. +func TestTemplateInitScaffoldIsOneWellFormedDocument(t *testing.T) { + tmpl := &framesv1.FrameTemplate{ + Id: "builtin:domain-vocabulary", Title: "Domain Vocabulary", + Description: "Terms.", Builtin: true, + Prefill: []byte("extends:\n - ref: acme/base\n version: \"1.0.0\"\n" + + "slots:\n terminology:\n - term: Frame\n definition: A scoped context artifact.\n"), + FieldRules: map[string]*framesv1.FieldRule{ + "terminology": {Level: framesv1.Requirement_REQUIREMENT_REQUIRED, Note: "One entry per term of art."}, + }, + } + url := testutil.NewStubServer(t, &testutil.StubService{GetTemplateFn: returns(tmpl)}) + dir := t.TempDir() + runCmd(t, url, "template", "init", "--template", "builtin:domain-vocabulary", "--dir", dir) + + b, err := os.ReadFile(filepath.Join(dir, "frame.yaml")) + if err != nil { + t.Fatalf("reading the scaffold: %v", err) + } + + // Exactly one document: decoding a second time off the same stream must + // report EOF. This is the assertion that actually catches a document + // separator sneaking into the prefill; everything below it is just + // making sure the one document that exists has the right shape. + dec := yaml.NewDecoder(bytes.NewReader(b)) + var doc map[string]any + if err := dec.Decode(&doc); err != nil { + t.Fatalf("decoding the scaffold: %v", err) + } + if err := dec.Decode(new(map[string]any)); err != io.EOF { + t.Fatalf("scaffold decoded as more than one YAML document (second Decode err = %v); "+ + "the prefill must not split it in two", err) + } + + for _, key := range []string{"name", "description", "version", "slots", "extends"} { + if _, ok := doc[key]; !ok { + t.Errorf("decoded scaffold missing top-level key %q: %#v", key, doc) + } + } + + // The prefill's structural content, not its raw text, has to survive. + slots, ok := doc["slots"].(map[string]any) + if !ok { + t.Fatalf("slots is not a map: %#v", doc["slots"]) + } + terminology, ok := slots["terminology"].([]any) + if !ok || len(terminology) != 1 { + t.Fatalf("slots.terminology = %#v, want exactly one entry", slots["terminology"]) + } + entry, ok := terminology[0].(map[string]any) + if !ok || entry["term"] != "Frame" || entry["definition"] != "A scoped context artifact." { + t.Errorf("slots.terminology[0] = %#v, want the prefill's term and definition", terminology[0]) + } + + // No duplicate top-level key. A map decode would collapse a repeated key + // silently (the second assignment just overwrites the first in Go), so + // this needs the raw node tree, not the decoded map. + var node yaml.Node + if err := yaml.Unmarshal(b, &node); err != nil { + t.Fatalf("parsing the scaffold as a node tree: %v", err) + } + mapping := node.Content[0] + seen := make(map[string]bool, len(mapping.Content)/2) + for i := 0; i < len(mapping.Content); i += 2 { + key := mapping.Content[i].Value + if seen[key] { + t.Errorf("scaffold has a duplicate top-level key %q", key) + } + seen[key] = true + } +} + +func TestTemplateInitRefusesToClobber(t *testing.T) { + tmpl := &framesv1.FrameTemplate{Id: "builtin:blank", Title: "Blank", Prefill: []byte("slots: {}\n")} + url := testutil.NewStubServer(t, &testutil.StubService{GetTemplateFn: returns(tmpl)}) + dir := t.TempDir() + path := filepath.Join(dir, "frame.yaml") + if err := os.WriteFile(path, []byte("name: mine\n"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := runCmdErr(t, url, "template", "init", "--template", "builtin:blank", "--dir", dir) + if err == nil { + t.Fatal("init overwrote an existing frame.yaml") + } + if !strings.Contains(err.Error(), "--force") { + t.Errorf("err = %v, want it to mention --force", err) + } + b, _ := os.ReadFile(path) + if string(b) != "name: mine\n" { + t.Errorf("the existing file was modified: %q", b) + } + + // The control: --force does overwrite. + if _, err := runCmdErr(t, url, "template", "init", "--template", "builtin:blank", "--dir", dir, "--force"); err != nil { + t.Fatalf("init --force: %v", err) + } + b, _ = os.ReadFile(path) + if string(b) == "name: mine\n" { + t.Error("--force did not overwrite") + } +} + +func TestTemplateInitRequiresATemplate(t *testing.T) { + url := testutil.NewStubServer(t, &testutil.StubService{}) + _, err := runCmdErr(t, url, "template", "init", "--dir", t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "--template") { + t.Fatalf("err = %v, want it to require --template", err) + } +} + +// The header is instructions an author follows, and it persists into the +// published content, so a command it names that only works once is a mistake +// that outlives the scaffold: --template asserts a new Frame, and the server +// refuses it as AlreadyExists on every publish after the first. +func TestTemplateInitScaffoldScopesTheTemplateFlagToTheFirstPublish(t *testing.T) { + tmpl := &framesv1.FrameTemplate{ + Id: "builtin:domain-vocabulary", Title: "Domain Vocabulary", + Description: "Terms.", Builtin: true, + Prefill: []byte("slots: {}\n"), + } + url := testutil.NewStubServer(t, &testutil.StubService{GetTemplateFn: returns(tmpl)}) + dir := t.TempDir() + runCmd(t, url, "template", "init", "--template", "builtin:domain-vocabulary", "--dir", dir) + + b, err := os.ReadFile(filepath.Join(dir, "frame.yaml")) + if err != nil { + t.Fatalf("reading the scaffold: %v", err) + } + got := string(b) + + // Every publish command the header offers, in the order it offers them. + var cmds []string + for _, line := range strings.Split(got, "\n") { + if trimmed := strings.TrimLeft(line, "# "); strings.HasPrefix(trimmed, "frames publish") { + cmds = append(cmds, trimmed) + } + } + if len(cmds) != 2 { + t.Fatalf("want two publish commands (the first version and every later one), got %v:\n%s", cmds, got) + } + if !strings.Contains(cmds[0], "--template builtin:domain-vocabulary") { + t.Errorf("the first-version command does not pass the template: %q", cmds[0]) + } + if strings.Contains(cmds[1], "--template") { + t.Errorf("the later-version command still passes --template: %q", cmds[1]) + } + // And it has to say which is which, or two bare commands are just confusing. + if !strings.Contains(got, "first version") { + t.Errorf("the header does not say the flag belongs to the first version:\n%s", got) + } +} diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go index e0038f9..935d745 100644 --- a/cli/internal/api/client.go +++ b/cli/internal/api/client.go @@ -49,11 +49,14 @@ func (t *tokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) return t.base.RoundTrip(req) } -// Publish publishes a new frame version. Returns the frame and the new version. -func (c *Client) Publish(ctx context.Context, content []byte, changelog string) (*framesv1.Frame, *framesv1.FrameVersion, error) { +// Publish publishes a new frame version. templateID is optional: when set, the +// server checks that template's required slots, which only applies when the +// Frame is being created. +func (c *Client) Publish(ctx context.Context, content []byte, changelog, templateID string) (*framesv1.Frame, *framesv1.FrameVersion, error) { resp, err := c.svc.PublishFrame(ctx, connect.NewRequest(&framesv1.PublishFrameRequest{ - Content: content, - Changelog: changelog, + Content: content, + Changelog: changelog, + TemplateId: templateID, })) if err != nil { return nil, nil, err @@ -105,3 +108,22 @@ func (c *Client) Me(ctx context.Context) (*framesv1.GetMeResponse, error) { } return resp.Msg, nil } + +// ListTemplates returns the templates the caller may start a Frame from - the +// built-ins plus their org's - and whether they may manage the org's. +func (c *Client) ListTemplates(ctx context.Context) ([]*framesv1.FrameTemplateSummary, bool, error) { + resp, err := c.svc.ListFrameTemplates(ctx, connect.NewRequest(&framesv1.ListFrameTemplatesRequest{})) + if err != nil { + return nil, false, err + } + return resp.Msg.Templates, resp.Msg.CanManage, nil +} + +// GetTemplate fetches one template with its prefill and rules. +func (c *Client) GetTemplate(ctx context.Context, id string) (*framesv1.FrameTemplate, error) { + resp, err := c.svc.GetFrameTemplate(ctx, connect.NewRequest(&framesv1.GetFrameTemplateRequest{Id: id})) + if err != nil { + return nil, err + } + return resp.Msg.Template, nil +} 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() diff --git a/docs/adr/0001-frame-templates-are-not-frames.md b/docs/adr/0001-frame-templates-are-not-frames.md new file mode 100644 index 0000000..04271d3 --- /dev/null +++ b/docs/adr/0001-frame-templates-are-not-frames.md @@ -0,0 +1,196 @@ +# 0001 - Frame templates are not Frames + +- **Status:** accepted +- **Date:** 2026-09-07 +- **Issue:** nebari-dev/nebari-frames#60 + +## Context + +A new Frame starts blank, which is the hardest starting point: an author faces +ten empty slots (terminology, rules, skills, prompts, and the rest of +`frames.SlotTable` in `backend/internal/frames/slots.go`) and no cue about +which of them their organization actually cares about. In practice most Frames +fall into a small number of recognizable kinds - a style guide, a glossary, a +tool-usage brief - and an org that has settled on its own house standard for +one of those kinds wants every new Frame of that kind to start the same way. +Frame templates exist to give an author a running start and let an org express +that standard once instead of restating it in every new Frame. + +The obvious modelling shortcut, and the one the originating issue suggested, is +to make a template a Frame marked as a template. That would reuse everything a +Frame already has for free: RBAC (`backend/internal/rbac/rbac.go`), versioning, +the publish path (`backend/internal/frames/service.go`), and both existing read +surfaces (the Connect API and the MCP resource list). This ADR records why that +shortcut was rejected, and what was built instead. + +## Decision + +A template is an authoring affordance local to this implementation. It is not a +Frame and not a Frame Spec concept: nothing in the wire format a `.frame.md` +document exchanges with another system knows templates exist. A template has +its own internal type (`Template` in `backend/internal/frames/templates.go`), +no version, and no per-item grants - org membership is what governs reads. +Managing one is admin-only: create, update, and delete all require an admin +caller, the same bar as member management rather than Frame authoring. A +template is copied once at Frame creation and then forgotten - editing or +deleting a template afterward cannot reach back into any Frame already made +from it. + +Built-ins are compiled into the binary: `backend/internal/frames/builtins.go` +embeds a directory of YAML files (`//go:embed builtins/*.yaml`) and parses them +at package `init`, so a malformed starter panics at startup rather than failing +at a user's first click on the picker. Org templates are rows in the +`frame_templates` table (`backend/internal/store/sqlite/migrations/006_frame_templates.sql`). +Both resolve into one list behind the same two read RPCs, `ListFrameTemplates` +and `GetFrameTemplate` (`backend/internal/frames/templates_service.go`), which +put built-ins first (Blank leading) and then an org's own rows. There is no +shadowing between the two: if an org template happens to share a built-in's +title, it sits beside it in the list rather than hiding or replacing it, and +each entry carries its own `builtin` flag so a client can tell which is which. + +A template carries two things. First, prefill content: a `Prefill` (slots plus +suggested `extends` parents) that is deliberately a strict subset of a full +Frame document, because a template seeds content and must never dictate a +Frame's identity - `ParsePrefill` in `templates.go` refuses a prefill that sets +`name`, `description`, `version`, `visibility`, `scope`, `maintainer`, or +`excludes`. Second, a per-slot rule: a `FieldRule{Level, Note}` where `Level` is +one of `optional` / `recommended` / `required` and `Note` is the template +author's own guidance for that slot. + +Requirements are checked once, at the create-from-template publish, and nowhere +else. `Template.Check` (`templates.go`) walks the required slots and reports the +ones a document leaves empty; `publish` (`service.go`) calls it only when the +request names a `template_id`, and merges its errors with the schema's own +violations so an author sees every problem in one response instead of +discovering the template's complaints after already fixing the schema's. +Nothing about the template - not its id, not which rules it had - is recorded +on the resulting Frame. + +## Consequences + +Positive: no cross-org change to `rbac.Can` was needed; there is no +reconciliation of seeded rows to run on upgrade when a built-in's rules change; +a template cannot be named in a Frame's `extends` list (there is no Frame to +name - see the rejected alternatives below), so the copy-once semantics cannot +be quietly violated by an author extending a "Frame" that is really a +template; and adding a slot to `SlotTable` touches neither the `frame_templates` +schema nor the proto, because prefill and field rules are stored as opaque +blobs (`prefill BLOB`, `field_rules BLOB` in migration 006) the same way +`frame_versions.content` is. + +Accepted, and important, stated plainly: `required` is an authoring aid, not a +control. Because the check runs only on the publish that names `template_id`, +an author can empty a required section on their very next publish and nothing +objects - `PublishRequest.TemplateID`'s own doc comment in `service.go` says a +later publish "is never re-checked." A caller who omits `template_id` skips the +checks entirely, and both the CLI (`frames publish`, where `--template` +defaults to empty in `cli/cmd/publish.go`) and `create_frame` over MCP (whose +`writeFrameInput` in `backend/internal/mcp/write.go` carries no template field +at all) can publish without ever naming one. Real governance - a rule that +still holds on a Frame's tenth version - needs a persistent model that +survives past creation, which is a different design; the alternatives below +explain what that would have cost and why it was not built instead. + +Also accepted: the CLI scaffold's guidance comments persist into the Frame's +stored content, because the Connect RPC stores the author's exact submitted +bytes rather than re-marshalling a parsed document (see `PublishFrame` in +`service.go`, and `scaffoldComment` in `cli/cmd/template.go`, which prints the +required and recommended slots and their notes as a leading YAML comment +block). They are stripped from composed and MCP output, which both read a +parsed `Doc` rather than the raw bytes, so the guidance never leaks into what +another Frame or an AI client sees. The alternative - printing the same +guidance to a terminal instead of writing it into the file - is worse: the +terminal scrolls away, and the author is left rereading a bare scaffold with no +memory of what each section was for. + +For the future MCP templates surface (nebari-dev/nebari-frames#61): templates +must be exposed as tools only, never as MCP resources. The `nebari-frame://` +namespace (`backend/internal/mcp/resources.go`, +`docs/design/2026-05-21-mcp-endpoint-design.md`) addresses Frame content that a +client can read, cache, and - through `extends` - resolve into a composed +document. A template is not that: it is a one-shot authoring affordance, and +listing it in that namespace would suggest to a client that it is something a +Frame can inherit from or that resolves the way a Frame does. This is worth +recording now, before that surface exists, precisely because nothing else +written down would stop a future implementer from reaching for the shape +that's already there. + +## Alternatives considered + +### Templates as Frames in a shared system org + +Rejected: it cannot work at all. `rbac.Can` (`backend/internal/rbac/rbac.go`) +opens with a cross-org deny - `if frameOrgID != caller.OrgID { return false, +nil }` - before it even reaches the admin check or the grant lookup, and +`ListFramesByOrg` is strictly single-org by construction. No caller could read +a Frame that lived in a system org distinct from their own, so every built-in +would be invisible to every real user unless `rbac.Can` itself changed. +Shipping a handful of starter documents is not a reason to modify the one +function every read and write path in the codebase depends on for its security +guarantee. + +### Templates as Frames seeded into every org + +Rejected: this would work, but at a cost that only grows. Each built-in would +need a row per org, so N built-ins times M orgs is N x M rows to keep in sync; +changing a built-in's wording would mean reconciling that row in every org on +every upgrade, a migration-shaped problem for what should be a static asset. +It would also pollute every list path that was never built to filter +templates out of Frames: `ListFramesByOrg`, the Connect `list_frames` RPC, and +the MCP resource listing would each need new filtering logic to hide entries +that are templates rather than real Frames. And because a Frame that happens +to be a template is still a Frame, it could be named in another Frame's +`extends` (`ExtendRef` in `backend/internal/frames/schema.go` is just a name +and an optional version) - which is exactly the coupling the copy-once +semantics of a template are meant to rule out. + +### Custom templates as an operator config file, like branding + +Rejected. This looked attractive by analogy to +`backend/internal/branding/branding.go`, which resolves an operator-supplied +JSON file mounted via `BRANDING_CONFIG_FILE` into runtime configuration - the +same shape of "per-deployment customization without a database row" that +custom templates might want. It looked especially apt because +`org_memberships` has `PRIMARY KEY (user_sub)` +(`backend/internal/store/sqlite/migrations/001_orgs_and_memberships.sql`): a +user belongs to at most one org, so a single mounted config file is, in +practice, already scoped to one org's population of users the way a +per-org table would be. But the actual requirement is that org members author +templates from inside the app and have them persist across restarts and +deploys - add one this afternoon, edit its wording tomorrow - and a file an +operator mounts at deploy time cannot become something an admin edits from the +web app. Branding answers a question only an operator has an opinion about; +templates answer a question a customer's own admin has an opinion about, and +needs a write path for. + +### Persistent enforcement, by live pointer or by snapshot + +Rejected in favor of checking at creation only, but this was the closest call. +A Frame could instead carry a live `template_id` and have every subsequent +publish re-run `Check` against whatever the template currently says. That +would give `required` real teeth, but a template edit would then retroactively +break every Frame that used to conform to it, and deleting a template could +make Frames that reference it unpublishable outright - a governance mechanism +that can reach back in time and break work an author already finished and +walked away from. A snapshot of the rules at creation time, stored on the +Frame, avoids that specific breakage and mirrors how `extends` already pins a +specific parent version rather than always following its latest - but it still +permanently couples a Frame to the template it started from, which contradicts +the same "authoring affordance, not an ongoing relationship" premise the rest +of this design rests on, and a Frame's schema and storage would carry that +coupling forever, for every Frame, whether or not its author ever cared about +the distinction after the first save. Creation-only checking keeps the promise +that a Frame made from a template is, from its second publish on, an entirely +ordinary Frame - at the accepted cost recorded above under Consequences. + +### A bare Requirement instead of FieldRule{level, note} + +Rejected in favor of pairing a level with a note from the start. A bare level +lets a template say "terminology is required" but not what this org actually +wants written there, which is the harder and more useful half of the +guidance an author needs. This was decided before shipping rather than patched +in afterward because the wire representation is a map from slot key to +`FieldRule` (`proto/frames/v1/frame.proto`): widening an existing map's value type from a +bare enum to a message is a breaking wire change for any client already +decoding the old shape, whereas shipping the message from the first version +costs nothing and leaves room to add fields later without breaking anyone. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..0c8d1b2 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,21 @@ +# Architecture Decision Records + +Each file here records one decision that was hard to reverse, contested, or +surprising, in the form: the context that forced a choice, the choice, its +consequences, and the alternatives that were rejected and why. + +An ADR is for reasoning that the code cannot carry. If a comment next to the +constraint would do the job, write the comment instead: a comment is read by +whoever is already looking at the code it explains, while an ADR is for the +reasoning that spans multiple files, or that a single comment would make too +long to sit next to the line it justifies. + +Design documents for whole features live in `docs/design/`. Those describe what +was built; these describe why one option was chosen over another. A design +doc can go stale as the feature evolves around it; an ADR does not, because it +is not a description of the current system but a record of a decision made at +a point in time. + +Numbered sequentially, `NNNN-kebab-title.md`, never renumbered. An ADR is not +edited once merged except to add a "Superseded by" line pointing at the ADR that +replaced it: the record of what was believed at the time is the point. diff --git a/docs/qa/e2e-token-acquisition.md b/docs/qa/e2e-token-acquisition.md new file mode 100644 index 0000000..5c6b28d --- /dev/null +++ b/docs/qa/e2e-token-acquisition.md @@ -0,0 +1,182 @@ +# E2E Test Token Acquisition from Keycloak + +This document describes how an automated e2e test can acquire a Keycloak token that the Nebari Frames backend actually accepts. + +## Overview + +The Nebari Frames backend validates incoming tokens against Keycloak's OIDC configuration. Automated tests must acquire a token that: +1. Is issued by the configured OIDC issuer (e.g., http://localhost:8081/realms/frames) +2. Has the correct audience claim (frames-web) +3. Is issued for an existing user in Keycloak + +## Important: Build Freshness + +The dev stack runs a prebuilt `./nebari-frames-server` binary at the repository root (gitignored) rather than building from the current source tree. This binary can become stale and serve code older than the working tree. If you are testing recently added server behavior, run `make build` before starting the stack, or you will test old code and misread the result as a missing feature. + +This document verifies token acceptance against GetMe and ListFrames endpoints because they exist in any build; these tests isolate the question of whether the backend accepts the token from the question of which build is running. Newer template RPCs were confirmed working separately against a freshly built server. + +## Test Environment Setup + +### Start the dev stack: +```bash +make dev-auth +``` + +This starts: +- Keycloak on http://localhost:8081 +- Backend API on http://localhost:5173 + +### Keycloak Configuration + +The dev realm (`dev/keycloak/frames-realm.json`) includes: +- Realm name: `frames` +- Seeded users: dev / dev, alice / alice, bob / bob +- Default web client: `frames-web` with `directAccessGrantsEnabled: false` + +The `frames-web` client has direct access grants disabled to support the PKCE flow used by the web app. For automated tests that need password grant, a purpose-provisioned test client is required. + +## Working Token Acquisition Flow + +### 1. Create a test client via Keycloak Admin API + +Acquire an admin token: +```bash +ADMIN_TOKEN=$(curl -s -X POST "http://localhost:8081/realms/master/protocol/openid-connect/token" \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=admin" \ + -d "password=admin" | jq -r '.access_token') +``` + +Create the test client `frames-test`: +```bash +curl -s -X POST "http://localhost:8081/admin/realms/frames/clients" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "frames-test", + "enabled": true, + "publicClient": true, + "directAccessGrantsEnabled": true, + "standardFlowEnabled": false, + "protocolMappers": [ + { + "name": "frames-web-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "frames-web", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + }' +``` + +The critical part is the audience mapper: it sets the `aud` claim in the token to `frames-web`, which matches what the backend's token validator expects. + +### 2. Acquire a token via password grant + +```bash +TOKEN_RESPONSE=$(curl -s -X POST "http://localhost:8081/realms/frames/protocol/openid-connect/token" \ + -d "grant_type=password" \ + -d "client_id=frames-test" \ + -d "username=dev" \ + -d "password=dev") + +ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token') +``` + +### 3. Use the token against the backend + +```bash +curl -s -X POST \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d '{}' \ + http://localhost:5173/frames.v1.FrameService/GetMe +``` + +Expected response (HTTP 200): +```json +{ + "subject": "f18f8763-c9e6-4746-bf60-be547008fac3", + "email": "dev@localhost", + "org": { + "id": "01KWH68FR03KBP25Q6ZYMKYAAV", + "slug": "dev-org", + "displayName": "Dev Org", + "createdAt": "2026-07-02T10:33:42Z" + }, + "role": "admin", + "canCreate": true +} +``` + +### 4. Verify with additional endpoints + +List user's frames: +```bash +curl -s -X POST \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d '{}' \ + http://localhost:5173/frames.v1.FrameService/ListFrames +``` + +## Key Takeaways + +1. **Audience claim matters**: The backend validates that the token's `aud` claim equals the expected audience. The frames-web client via the audience mapper adds this. + +2. **Purpose-provisioned test client**: The production web client (`frames-web`) has `directAccessGrantsEnabled: false` to enforce the PKCE flow. A separate test client (`frames-test`) with password grant enabled is required for automated tests. + +3. **Also proven on a cluster**: this procedure began as local-dev-only, but the same shape now works against an operator-provisioned realm; see the section below for the two differences that matter. Issuer URLs, audience requirements, and claim mappings still vary between deployments, so validate against your actual OIDC configuration. + +## Against a Real Cluster + +Verified 2026-09-08 on a kind cluster running nebari-infrastructure-core, and mirrored by the `e2e-sandbox` CI job. Two things differ from local dev: which identity the backend expects, and which certificate the gateway serves. + +**The audience is the operator's SPA client, not a name you can predict.** The chart wires `OIDC_CLIENT_ID` to the `spa-client-id` key of the secret the operator writes (`-oidc-client`), so that is what the token's `aud` must contain. Read it from the secret rather than re-deriving the `--spa` naming convention, so a convention change fails loudly at the read instead of silently at token validation: + +```bash +kubectl -n nebari-frames get secret nebari-frames-nebari-frames-oidc-client \ + -o jsonpath='{.data.spa-client-id}' | base64 -d +``` + +The operator's SPA client has `directAccessGrantsEnabled: false`, so tests still need their own client whose audience mapper points at that id. + +Keycloak's declarative user profile is enabled by default on the realm and marks `firstName`/`lastName` required. A user created without them is left incomplete and the password grant refuses with `Account is not fully set up`, which names no specific unmet condition. Set both, and set `requiredActions: []` explicitly so a realm default such as `UPDATE_PASSWORD` cannot block the grant the same way. + +**TLS: pin the app's own certificate, not the gateway's.** The nebari-operator gives each NebariApp its own Gateway listener with its own certificate whose only SAN is that app's hostname, and Envoy serves it for that SNI. `nebari-gateway-tls` is only the catch-all listener's certificate (SANs: apex, keycloak, argocd). Pinning it for an app hostname fails with a presented-versus-pinned mismatch. Resolve the certificate from the listener under test: + +```bash +GW_NS=$(kubectl get gateway -A \ + -o jsonpath='{.items[?(@.metadata.name=="nebari-gateway")].metadata.namespace}') +CERT_SECRET=$(kubectl -n "${GW_NS}" get gateway nebari-gateway \ + -o jsonpath='{.spec.listeners[?(@.hostname=="frames.example.com")].tls.certificateRefs[0].name}') +kubectl -n "${GW_NS}" get secret "${CERT_SECRET}" \ + -o jsonpath='{.data.tls\.crt}' | base64 -d > gateway-tls.crt +``` + +These certificates are self-signed leaves (`CA:FALSE`), issued by a self-signed cert-manager ClusterIssuer, with no CA anywhere to chain to. Go does accept such a certificate as its own trust anchor - `crypto/x509` short-circuits on `opts.Roots.contains(c)` and returns a one-element chain without consulting basic constraints - so "unknown authority" from a bundle built this way means the certificate is the *wrong* one, not that Go rejected a non-CA. `e2e/harness.go` pins instead of chaining for this case; see its comment. + +Note that the pod's own trust bundle (`trustBundle.configMapName`, surfaced as `SSL_CERT_FILE`) is a separate concern, and `SSL_CERT_FILE` *replaces* Go's root pool rather than extending it. It must therefore carry public roots plus the private CA, or TLS to anything publicly signed breaks. + +## Troubleshooting + +**Token rejected with 401 "invalid or expired token"**: Check that the token's `aud` claim matches the backend's expectation (frames-web). Decode the JWT payload to verify: +```bash +echo "${ACCESS_TOKEN}" | cut -d'.' -f2 | base64 -d | jq . +``` + +**Password grant returns "unauthorized_client"**: The client has `directAccessGrantsEnabled: false`. Either use a different flow (PKCE, client credentials) or create a new client with password grant enabled. + +## Cleanup + +Stop the dev stack: +```bash +make dev-clean +``` + +This stops Keycloak and cleans up the SQLite database lock. diff --git a/docs/site/src/content/docs/reference/cli/frames.md b/docs/site/src/content/docs/reference/cli/frames.md index eb5a04e..f85fe04 100644 --- a/docs/site/src/content/docs/reference/cli/frames.md +++ b/docs/site/src/content/docs/reference/cli/frames.md @@ -30,4 +30,5 @@ and print the inheritance-resolved form of a Frame. * [frames publish](/reference/cli/frames_publish/) - Publish a Frame from a directory containing frame.yaml * [frames resolve](/reference/cli/frames_resolve/) - Print the inheritance-resolved form of a Frame * [frames show](/reference/cli/frames_show/) - Show a Frame's metadata and contents +* [frames template](/reference/cli/frames_template/) - Browse Frame templates and scaffold a Frame from one diff --git a/docs/site/src/content/docs/reference/cli/frames_publish.md b/docs/site/src/content/docs/reference/cli/frames_publish.md index 55c60fd..b8ceae8 100644 --- a/docs/site/src/content/docs/reference/cli/frames_publish.md +++ b/docs/site/src/content/docs/reference/cli/frames_publish.md @@ -16,6 +16,7 @@ frames publish [flags] --changelog string Release notes for this version --dir string Directory containing frame.yaml -h, --help help for publish + --template string Template this Frame is being created from; asserts the Frame is new and checks the template's required sections ``` ### Options inherited from parent commands diff --git a/docs/site/src/content/docs/reference/cli/frames_template.md b/docs/site/src/content/docs/reference/cli/frames_template.md new file mode 100644 index 0000000..fad6439 --- /dev/null +++ b/docs/site/src/content/docs/reference/cli/frames_template.md @@ -0,0 +1,27 @@ +--- +title: frames template +--- + +## frames template + +Browse Frame templates and scaffold a Frame from one + +### Options + +``` + -h, --help help for template +``` + +### Options inherited from parent commands + +``` + --api-url string Registry URL; overrides api_url config and FRAMES_API_URL + --credentials-path string Credentials file path (for testing) +``` + +### SEE ALSO + +* [frames](/reference/cli/frames/) - Publish, browse, and resolve Nebari Frames +* [frames template init](/reference/cli/frames_template_init/) - Write a frame.yaml scaffold from a template, ready to fill in and publish +* [frames template list](/reference/cli/frames_template_list/) - List the Frame templates you can start from + diff --git a/docs/site/src/content/docs/reference/cli/frames_template_init.md b/docs/site/src/content/docs/reference/cli/frames_template_init.md new file mode 100644 index 0000000..362dbd3 --- /dev/null +++ b/docs/site/src/content/docs/reference/cli/frames_template_init.md @@ -0,0 +1,32 @@ +--- +title: frames template init +--- + +## frames template init + +Write a frame.yaml scaffold from a template, ready to fill in and publish + +``` +frames template init [flags] +``` + +### Options + +``` + --dir string Directory to write frame.yaml into (default ".") + --force Overwrite an existing frame.yaml + -h, --help help for init + --template string Template id from frames template list +``` + +### Options inherited from parent commands + +``` + --api-url string Registry URL; overrides api_url config and FRAMES_API_URL + --credentials-path string Credentials file path (for testing) +``` + +### SEE ALSO + +* [frames template](/reference/cli/frames_template/) - Browse Frame templates and scaffold a Frame from one + diff --git a/docs/site/src/content/docs/reference/cli/frames_template_list.md b/docs/site/src/content/docs/reference/cli/frames_template_list.md new file mode 100644 index 0000000..c349197 --- /dev/null +++ b/docs/site/src/content/docs/reference/cli/frames_template_list.md @@ -0,0 +1,29 @@ +--- +title: frames template list +--- + +## frames template list + +List the Frame templates you can start from + +``` +frames template list [flags] +``` + +### Options + +``` + -h, --help help for list +``` + +### Options inherited from parent commands + +``` + --api-url string Registry URL; overrides api_url config and FRAMES_API_URL + --credentials-path string Credentials file path (for testing) +``` + +### SEE ALSO + +* [frames template](/reference/cli/frames_template/) - Browse Frame templates and scaffold a Frame from one + diff --git a/e2e/harness.go b/e2e/harness.go new file mode 100644 index 0000000..73cd5aa --- /dev/null +++ b/e2e/harness.go @@ -0,0 +1,142 @@ +//go:build e2e + +// Package e2e drives a deployed nebari-frames through its public gateway with a +// real Keycloak token. It is build-tagged and environment-gated so `make test` +// never reaches it: these tests need a cluster, and a suite that silently +// no-ops when one is absent would be worse than one that is obviously skipped. +// +// It uses the generated Connect client rather than curl, so the requests are +// typed and the suite runs locally against `make dev` as easily as against the +// sandbox. +package e2e + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "os" + "testing" + "time" + + "github.com/nebari-dev/nebari-frames/gen/go/frames/v1/framesv1connect" +) + +const ( + envBaseURL = "FRAMES_E2E_BASE_URL" + envToken = "FRAMES_E2E_TOKEN" + envCACert = "FRAMES_E2E_CA_CERT" +) + +func baseURL() string { return os.Getenv(envBaseURL) } + +// transport verifies the server against the certificate named by +// FRAMES_E2E_CA_CERT, falling back to the default transport when unset. +// +// Two shapes of certificate turn up here and they need different handling. +// +// A real CA becomes a trust anchor and Go verifies the chain normally. +// +// The Nebari gateway instead serves a SELF-SIGNED LEAF: subject equals issuer, +// `CA:FALSE` marked critical, and a presented chain of one. There is no CA +// anywhere in that deployment to chain to. Go does accept such a certificate as +// its own anchor: crypto/x509 short-circuits on `opts.Roots.contains(c)` and +// returns a one-element chain without ever calling buildChains, so basic +// constraints go unconsulted. But leaning on that means depending on an x509 +// internal to do what is really just an exact-match check. +// +// So that case is PINNED explicitly, stating the requirement in this code +// instead of inheriting it from x509's. InsecureSkipVerify turns off Go's +// chain building, and VerifyPeerCertificate then applies a stricter test than a +// chain would: the certificate the server presents must be byte-identical to the +// one on disk. Any other certificate, expired or not, signed by anyone, fails. +// This is pinning and it fails closed; it is not skipped verification, and the +// suite would be worthless if it were. +func transport(t *testing.T) http.RoundTripper { + t.Helper() + path := os.Getenv(envCACert) + if path == "" { + return http.DefaultTransport + } + pemBytes, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s=%q: %v", envCACert, path, err) + } + block, _ := pem.Decode(pemBytes) + if block == nil { + t.Fatalf("%s=%q held no PEM block", envCACert, path) + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("%s=%q is not a parseable certificate: %v", envCACert, path, err) + } + + if cert.IsCA { + roots, err := x509.SystemCertPool() + if err != nil { + roots = x509.NewCertPool() + } + roots.AddCert(cert) + return &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, + } + } + + pinned := cert.Raw + return &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + // Chain verification is replaced, not dropped: see VerifyPeerCertificate. + InsecureSkipVerify: true, //nolint:gosec // pinned below + VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + for _, raw := range rawCerts { + if bytes.Equal(raw, pinned) { + return nil + } + } + return fmt.Errorf( + "server presented %d certificate(s), none matching the one pinned from %s", + len(rawCerts), path) + }, + }, + } +} + +// newClient returns a client carrying the caller's bearer token, skipping the +// test when the environment is not set up. It reports which variable is missing: +// "skipped" with no reason is how a suite quietly stops running. +func newClient(t *testing.T) framesv1connect.FrameServiceClient { + t.Helper() + url, token := baseURL(), os.Getenv(envToken) + if url == "" || token == "" { + t.Skipf("set %s and %s to run the e2e suite", envBaseURL, envToken) + } + return framesv1connect.NewFrameServiceClient(&http.Client{ + Timeout: 30 * time.Second, + Transport: bearer{base: transport(t), token: token}, + }, url) +} + +// newAnonClient returns a client with no credentials, for asserting that the +// endpoint actually rejects unauthenticated callers. +func newAnonClient(t *testing.T) framesv1connect.FrameServiceClient { + t.Helper() + if baseURL() == "" { + t.Skipf("set %s to run the e2e suite", envBaseURL) + } + return framesv1connect.NewFrameServiceClient( + &http.Client{Timeout: 30 * time.Second, Transport: transport(t)}, baseURL()) +} + +type bearer struct { + base http.RoundTripper + token string +} + +func (b bearer) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer "+b.token) + return b.base.RoundTrip(req) +} diff --git a/e2e/templates_test.go b/e2e/templates_test.go new file mode 100644 index 0000000..9b468bf --- /dev/null +++ b/e2e/templates_test.go @@ -0,0 +1,299 @@ +//go:build e2e + +package e2e + +import ( + "context" + "errors" + "strconv" + "testing" + "time" + + "connectrpc.com/connect" + + framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1" + "github.com/nebari-dev/nebari-frames/gen/go/frames/v1/framesv1connect" +) + +// TestAuthIsEnforced is the guard against a vacuous suite. Everything below +// asserts what an authenticated caller can do; without this, a deployment that +// accepted anonymous requests would make all of it pass for the wrong reason. +func TestAuthIsEnforced(t *testing.T) { + ctx := context.Background() + anon := newAnonClient(t) + _, err := anon.ListFrameTemplates(ctx, connect.NewRequest(&framesv1.ListFrameTemplatesRequest{})) + if err == nil { + t.Fatal("an unauthenticated caller listed templates") + } + if code := connect.CodeOf(err); code != connect.CodeUnauthenticated { + t.Fatalf("code = %v (err %v), want Unauthenticated", code, err) + } +} + +func TestBuiltinTemplatesAreServed(t *testing.T) { + ctx := context.Background() + client := newClient(t) + resp, err := client.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 + } + for _, want := range []string{ + "builtin:blank", "builtin:brand-voice", "builtin:business-process", + "builtin:domain-vocabulary", "builtin:engineering-norms", "builtin:product-context", + } { + got, ok := byID[want] + if !ok { + t.Errorf("built-in %q is missing from a real deployment", want) + continue + } + if !got.Builtin { + t.Errorf("%q is not flagged builtin", want) + } + if got.Title == "" || got.Description == "" { + t.Errorf("%q has an empty title or description: %+v", want, got) + } + } + // The seeded e2e user is the org admin, so they may manage templates. + if !resp.Msg.CanManage { + t.Error("can_manage = false for the seeded admin") + } +} + +// createTemplate makes an org template and removes it when the test ends. The +// suite runs against a live deployment, so leaving rows behind would make later +// runs depend on earlier ones. +func createTemplate(t *testing.T, client framesv1connect.FrameServiceClient, title string, rules map[string]*framesv1.FieldRule) *framesv1.FrameTemplate { + t.Helper() + ctx := context.Background() + resp, err := client.CreateFrameTemplate(ctx, connect.NewRequest(&framesv1.CreateFrameTemplateRequest{ + Title: title, Description: "created by the e2e suite", FieldRules: rules, + })) + if err != nil { + t.Fatalf("create template %q: %v", title, err) + } + id := resp.Msg.Template.Id + t.Cleanup(func() { + _, _ = client.DeleteFrameTemplate(context.Background(), + connect.NewRequest(&framesv1.DeleteFrameTemplateRequest{Id: id})) + }) + return resp.Msg.Template +} + +func listIDs(t *testing.T, client framesv1connect.FrameServiceClient) map[string]*framesv1.FrameTemplateSummary { + t.Helper() + resp, err := client.ListFrameTemplates(context.Background(), + connect.NewRequest(&framesv1.ListFrameTemplatesRequest{})) + if err != nil { + t.Fatalf("list: %v", err) + } + out := map[string]*framesv1.FrameTemplateSummary{} + for _, s := range resp.Msg.Templates { + out[s.Id] = s + } + return out +} + +// TestOrgTemplateLifecycle is journey 5 through the real stack. +func TestOrgTemplateLifecycle(t *testing.T) { + ctx := context.Background() + client := newClient(t) + title := "E2E House Style " + uniqueSuffix(t) + + created := createTemplate(t, client, title, map[string]*framesv1.FieldRule{ + "style": {Level: framesv1.Requirement_REQUIREMENT_REQUIRED, Note: "Voice and tone."}, + }) + if created.Builtin { + t.Error("a created template came back flagged builtin") + } + + listed := listIDs(t, client) + if got, ok := listed[created.Id]; !ok { + t.Fatalf("the created template is not in the list") + } else if got.Builtin || got.Title != title { + t.Errorf("listed as %+v, want title %q and builtin=false", got, title) + } + + renamed := title + " v2" + if _, err := client.UpdateFrameTemplate(ctx, connect.NewRequest(&framesv1.UpdateFrameTemplateRequest{ + Id: created.Id, Title: renamed, Description: "renamed by the e2e suite", + FieldRules: map[string]*framesv1.FieldRule{ + "style": {Level: framesv1.Requirement_REQUIREMENT_RECOMMENDED, Note: "Softened."}, + }, + })); err != nil { + t.Fatalf("update: %v", err) + } + if got := listIDs(t, client)[created.Id]; got == nil || got.Title != renamed { + t.Errorf("after update, listed as %+v, want title %q", got, renamed) + } + + fetched, err := client.GetFrameTemplate(ctx, connect.NewRequest(&framesv1.GetFrameTemplateRequest{Id: created.Id})) + if err != nil { + t.Fatalf("get after update: %v", err) + } + if fetched.Msg.Template.FieldRules["style"].Level != framesv1.Requirement_REQUIREMENT_RECOMMENDED { + t.Errorf("the rule was not updated: %+v", fetched.Msg.Template.FieldRules) + } + + if _, err := client.DeleteFrameTemplate(ctx, connect.NewRequest(&framesv1.DeleteFrameTemplateRequest{ + Id: created.Id, + })); err != nil { + t.Fatalf("delete: %v", err) + } + if _, still := listIDs(t, client)[created.Id]; still { + t.Error("the template is still listed after being deleted") + } + if _, err := client.GetFrameTemplate(ctx, connect.NewRequest(&framesv1.GetFrameTemplateRequest{ + Id: created.Id, + })); connect.CodeOf(err) != connect.CodeNotFound { + t.Errorf("get after delete: code = %v (err %v), want NotFound", connect.CodeOf(err), err) + } +} + +// TestBuiltinsAreImmutableOverTheWire is journey 7. +func TestBuiltinsAreImmutableOverTheWire(t *testing.T) { + ctx := context.Background() + client := newClient(t) + _, updateErr := client.UpdateFrameTemplate(ctx, connect.NewRequest(&framesv1.UpdateFrameTemplateRequest{ + Id: "builtin:blank", 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 := client.DeleteFrameTemplate(ctx, connect.NewRequest(&framesv1.DeleteFrameTemplateRequest{ + Id: "builtin:blank", + })) + if connect.CodeOf(deleteErr) != connect.CodeInvalidArgument { + t.Errorf("delete builtin: code = %v (err %v), want InvalidArgument", connect.CodeOf(deleteErr), deleteErr) + } + // The control: it is still there and still readable. + if _, err := client.GetFrameTemplate(ctx, connect.NewRequest(&framesv1.GetFrameTemplateRequest{ + Id: "builtin:blank", + })); err != nil { + t.Errorf("the built-in became unreadable: %v", err) + } +} + +// TestRequiredSlotRefusesThenAccepts is journey 3. It walks the whole +// refusal-then-success path through the gateway, and asserts the +// FieldViolations detail survives the wire: without the detail the web form +// cannot put the message on the section. +func TestRequiredSlotRefusesThenAccepts(t *testing.T) { + ctx := context.Background() + client := newClient(t) + name := "e2e-vocab-" + uniqueSuffix(t) + t.Cleanup(func() { + _, _ = client.DeleteFrame(context.Background(), connect.NewRequest(&framesv1.DeleteFrameRequest{ + OrgSlug: orgSlug(t, client), Name: name, Force: true, + })) + }) + + empty := []byte("name: " + name + "\ndescription: E2E vocabulary\nversion: 1.0.0\nslots: {}\n") + _, err := client.PublishFrame(ctx, connect.NewRequest(&framesv1.PublishFrameRequest{ + Content: empty, TemplateId: "builtin:domain-vocabulary", + })) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("publish with an empty required slot: code = %v (err %v), want InvalidArgument", connect.CodeOf(err), err) + } + var ce *connect.Error + if !errors.As(err, &ce) { + t.Fatalf("not a connect error: %v", err) + } + found := false + 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 { + if v.Field == "slots.terminology" { + found = true + } + } + } + } + if !found { + t.Error("no FieldViolations detail naming slots.terminology survived the wire") + } + + filled := []byte("name: " + name + "\ndescription: E2E vocabulary\nversion: 1.0.0\n" + + "slots:\n terminology:\n - term: Frame\n definition: A scoped context artifact.\n") + if _, err := client.PublishFrame(ctx, connect.NewRequest(&framesv1.PublishFrameRequest{ + Content: filled, TemplateId: "builtin:domain-vocabulary", + })); err != nil { + t.Fatalf("publish with the slot filled: %v", err) + } +} + +// TestAFrameFromATemplateStaysOrdinary is journey 9. +func TestAFrameFromATemplateStaysOrdinary(t *testing.T) { + ctx := context.Background() + client := newClient(t) + suffix := uniqueSuffix(t) + tmpl := createTemplate(t, client, "E2E Vocabulary "+suffix, map[string]*framesv1.FieldRule{ + "terminology": {Level: framesv1.Requirement_REQUIREMENT_REQUIRED, Note: "Define your terms."}, + }) + name := "e2e-ordinary-" + suffix + org := orgSlug(t, client) + t.Cleanup(func() { + _, _ = client.DeleteFrame(context.Background(), connect.NewRequest(&framesv1.DeleteFrameRequest{ + OrgSlug: org, Name: name, Force: true, + })) + }) + + if _, err := client.PublishFrame(ctx, connect.NewRequest(&framesv1.PublishFrameRequest{ + Content: []byte("name: " + name + "\ndescription: E2E ordinary\nversion: 1.0.0\n" + + "slots:\n terminology:\n - term: Frame\n definition: A scoped context artifact.\n"), + TemplateId: tmpl.Id, + })); err != nil { + t.Fatalf("create from template: %v", err) + } + + // Delete the template out from under the Frame. + if _, err := client.DeleteFrameTemplate(ctx, connect.NewRequest(&framesv1.DeleteFrameTemplateRequest{ + Id: tmpl.Id, + })); err != nil { + t.Fatalf("delete template: %v", err) + } + + // The Frame still resolves. + if _, err := client.ResolveFrame(ctx, connect.NewRequest(&framesv1.ResolveFrameRequest{ + OrgSlug: org, Name: name, + })); err != nil { + t.Errorf("resolve after the template was deleted: %v", err) + } + + // And a later version may empty the once-required section, because nothing + // was ever recorded on the Frame. + if _, err := client.PublishFrame(ctx, connect.NewRequest(&framesv1.PublishFrameRequest{ + Content: []byte("name: " + name + "\ndescription: E2E ordinary\nversion: 1.0.1\nslots: {}\n"), + })); err != nil { + t.Errorf("publishing an update that empties the required slot: %v", err) + } +} + +// orgSlug asks the server which org the caller is in, rather than hardcoding the +// chart's seed value: the suite should still work against a differently seeded +// deployment. +func orgSlug(t *testing.T, client framesv1connect.FrameServiceClient) string { + t.Helper() + resp, err := client.GetMe(context.Background(), connect.NewRequest(&framesv1.GetMeRequest{})) + if err != nil { + t.Fatalf("GetMe: %v", err) + } + if resp.Msg.Org == nil { + t.Fatal("GetMe returned no org") + } + return resp.Msg.Org.Slug +} + +// uniqueSuffix keeps names from colliding between concurrent or repeated runs +// against the same deployment. +func uniqueSuffix(t *testing.T) string { + t.Helper() + return strconv.FormatInt(time.Now().UnixNano(), 36) +} 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..0fbdfba 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"` @@ -718,7 +730,15 @@ func (x *FieldViolation) GetMessage() string { // FieldViolations is attached as a Connect error detail on PublishFrame when // schema validation fails, so clients can map failures to individual inputs. // ConvertFrame reuses it for markdown structure errors, with field "markdown" -// and a message naming the offending line. +// and a message naming the offending line. The template write RPCs reuse it for +// a prefill whose content could not be published. +// +// It is not tied to InvalidArgument. PublishFrame also attaches it on +// AlreadyExists, naming "name" when the frame name is already taken - that code +// covers two different collisions, a taken name and a republished version, and +// they are fixed in different inputs. A republished version names no field, so +// a client with no violation to read should treat AlreadyExists as a version +// conflict. type FieldViolations struct { state protoimpl.MessageState `protogen:"open.v1"` Violations []*FieldViolation `protobuf:"bytes,1,rep,name=violations,proto3" json:"violations,omitempty"` @@ -1412,14 +1432,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 +2007,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 +2052,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 +2077,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 +2209,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..83f6083 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; }; /** @@ -327,7 +337,15 @@ export const FieldViolationSchema: GenMessage = /*@__PURE__*/ * FieldViolations is attached as a Connect error detail on PublishFrame when * schema validation fails, so clients can map failures to individual inputs. * ConvertFrame reuses it for markdown structure errors, with field "markdown" - * and a message naming the offending line. + * and a message naming the offending line. The template write RPCs reuse it for + * a prefill whose content could not be published. + * + * It is not tied to InvalidArgument. PublishFrame also attaches it on + * AlreadyExists, naming "name" when the frame name is already taken - that code + * covers two different collisions, a taken name and a republished version, and + * they are fixed in different inputs. A republished version names no field, so + * a client with no violation to read should treat AlreadyExists as a version + * conflict. * * @generated from message frames.v1.FieldViolations */ @@ -621,6 +639,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 +929,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..7e391f0 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; @@ -96,7 +115,15 @@ message FieldViolation { // FieldViolations is attached as a Connect error detail on PublishFrame when // schema validation fails, so clients can map failures to individual inputs. // ConvertFrame reuses it for markdown structure errors, with field "markdown" -// and a message naming the offending line. +// and a message naming the offending line. The template write RPCs reuse it for +// a prefill whose content could not be published. +// +// It is not tied to InvalidArgument. PublishFrame also attaches it on +// AlreadyExists, naming "name" when the frame name is already taken - that code +// covers two different collisions, a taken name and a republished version, and +// they are fixed in different inputs. A republished version names no field, so +// a client with no violation to read should treat AlreadyExists as a version +// conflict. message FieldViolations { repeated FieldViolation violations = 1; } @@ -157,3 +184,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 {} 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/FieldError.tsx b/web/src/components/form/FieldError.tsx index f96403e..f11a358 100644 --- a/web/src/components/form/FieldError.tsx +++ b/web/src/components/form/FieldError.tsx @@ -2,10 +2,21 @@ import { useFormContext, get } from "react-hook-form"; // Reads the message at a react-hook-form error path. Paths are dotted and may // index arrays ("slots.terminology.2.definition"), which is what get() handles. +// +// The `root` fallback is not an alternative spelling: @hookform/resolvers +// (toNestErrors) nests an error under `.root` whenever that path has +// registered children, which is what a rule about a whole array produces - a +// duplicate-term refine over `slots.terminology`, say, while +// `slots.terminology.0.term` is registered. Reading only `.message` there +// finds an object with none, so the form refuses to submit and shows nothing: +// a Save button that silently does nothing at all. export function useFieldError(name: string): string | undefined { const { formState } = useFormContext(); - const e = get(formState.errors, name) as { message?: string } | undefined; - return typeof e?.message === "string" ? e.message : undefined; + const e = get(formState.errors, name) as + | { message?: string; root?: { message?: string } } + | undefined; + const message = typeof e?.message === "string" ? e.message : e?.root?.message; + return typeof message === "string" ? message : undefined; } // Stable id for the error text, so an input can point at it via diff --git a/web/src/components/form/FieldRuleEditor.test.tsx b/web/src/components/form/FieldRuleEditor.test.tsx new file mode 100644 index 0000000..28185ca --- /dev/null +++ b/web/src/components/form/FieldRuleEditor.test.tsx @@ -0,0 +1,43 @@ +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"; +import { Requirement } from "@gen/frames/v1/frame_pb"; + +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({ + level: Requirement.REQUIRED, + 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/components/frame/TemplatePicker.test.tsx b/web/src/components/frame/TemplatePicker.test.tsx new file mode 100644 index 0000000..924c293 --- /dev/null +++ b/web/src/components/frame/TemplatePicker.test.tsx @@ -0,0 +1,70 @@ +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"); +}); + +it("shows the error in place of the groups, so an empty picker is never mistaken for an empty catalog", async () => { + const onRetry = vi.fn(); + render( + , + ); + expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument(); + // The group headings are what make a blank screen read as an answer. + expect(screen.queryByRole("heading", { name: /built-in/i })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /try again/i })); + expect(onRetry).toHaveBeenCalled(); +}); + +it("shows the groups, not an error, when there is no error", () => { + render(); + expect(screen.getByRole("heading", { name: /built-in/i })).toBeInTheDocument(); + expect(screen.queryByText(/could not be loaded/i)).not.toBeInTheDocument(); +}); diff --git a/web/src/components/frame/TemplatePicker.tsx b/web/src/components/frame/TemplatePicker.tsx new file mode 100644 index 0000000..1ff115e --- /dev/null +++ b/web/src/components/frame/TemplatePicker.tsx @@ -0,0 +1,90 @@ +import type { FrameTemplateSummary } from "@gen/frames/v1/frame_pb"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertTitle, AlertAction } from "@/components/ui/alert"; + +// 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, + failed, + onRetry, +}: { + templates: FrameTemplateSummary[]; + onPick: (id: string) => void; + // Whether the list failed to load. Without this a failed list renders as the + // heading, the intro, and no cards - a screen that looks like an answer + // ("there are no templates") to a question that was never answered. + // + // A flag rather than the server's message: read failures across this app + // state a fixed sentence, because the text on a failed read is storage or + // wiring detail and retrying is the only thing the reader can act on. + failed?: boolean; + onRetry?: () => 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. +

+
+ {failed ? ( + + {/* No cause is stated: the client cannot tell an unreachable + registry from one that answered with an error, and the case that + prompted this - a database missing the templates table - is the + second. */} + Templates could not be loaded + {onRetry && ( + + + + )} + + ) : ( + <> + !t.builtin)} onPick={onPick} /> + t.builtin)} onPick={onPick} /> + + )} +
+ ); +} diff --git a/web/src/lib/authoring-schema.ts b/web/src/lib/authoring-schema.ts index aff6993..b805380 100644 --- a/web/src/lib/authoring-schema.ts +++ b/web/src/lib/authoring-schema.ts @@ -10,7 +10,14 @@ const termSchema = z.object({ const nonEmptyList = z.array(z.string().trim().min(1, "must not be empty")).optional(); -const slotsSchema = z.object({ +// The content rules both authoring surfaces enforce, exported because a +// template's prefill is edited with the same editors and stored through the +// same schema as a Frame's slots: `frame-yaml.ts` carries the decode shape +// (deliberately lenient, since it also has to read whatever is already +// stored), and this is the shape a human is allowed to submit. Backend +// counterpart: contentErrors in backend/internal/frames/validate.go, shared by +// a publish and a template write for the same reason. +export const contentSlotsSchema = z.object({ terminology: z .array(termSchema) .optional() @@ -50,7 +57,7 @@ export const authoringSchema = z.object({ maintainer: z.string(), extends: z.array(extendSchema).optional(), excludes: z.array(z.string().trim().min(1)).optional(), - slots: slotsSchema, + slots: contentSlotsSchema, }) satisfies z.ZodType; // Resolver schema for the form: includes the version-scoped changelog. Used so diff --git a/web/src/lib/frame-yaml.test.ts b/web/src/lib/frame-yaml.test.ts index 776c862..4c14a19 100644 --- a/web/src/lib/frame-yaml.test.ts +++ b/web/src/lib/frame-yaml.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseFrameContent, serializeFrameDoc, type FrameDoc } from "./frame-yaml"; +import { parseFrameContent, serializeFrameDoc, serializeFramePrefill, type FrameDoc } from "./frame-yaml"; const yamlDoc = ` name: brand-voice @@ -82,4 +82,71 @@ slots: // re-parse must succeed (no stray keys for KnownFields(true)) expect(parseFrameContent(out).name).toBe("minimal"); }); + + it("drops blank list rows rather than serializing them", () => { + // A list editor leaves an empty row behind the moment an author adds one + // and thinks better of it. The backend refuses those rows + // (slots.rules[0]: must not be empty), so serializing them turns a stray + // keystroke into a publish failure pointing at a row nobody wrote - and in + // a template prefill it fails later, in the author's scaffold. + const doc: FrameDoc = { + name: "minimal", + description: "d", + version: "1.0.0", + visibility: "", + scope: "", + maintainer: "", + slots: { + rules: ["A real rule", "", " "], + skills: [""], + prompts: ["Ask what changed"], + }, + }; + const parsed = parseFrameContent(serializeFrameDoc(doc)); + expect(parsed.slots.rules).toEqual(["A real rule"]); + expect(parsed.slots.prompts).toEqual(["Ask what changed"]); + // A list left with nothing in it is not a list. + expect(parsed.slots.skills).toBeUndefined(); + }); + + it("drops a terminology row the author added and left entirely empty", () => { + // "+ Add term" appends {term: "", definition: ""}, so an abandoned row is + // as easy to produce here as in a list - and the backend refuses it the + // same way. A row with one side filled is a real error the author should + // see, so it survives to be reported. + const doc: FrameDoc = { + name: "minimal", + description: "d", + version: "1.0.0", + visibility: "", + scope: "", + maintainer: "", + slots: { + terminology: [ + { term: "Frame", definition: "A scoped context artifact." }, + { term: " ", definition: "" }, + { term: "Slot", definition: "" }, + ], + }, + }; + const parsed = parseFrameContent(serializeFrameDoc(doc)); + expect(parsed.slots.terminology).toEqual([ + { term: "Frame", definition: "A scoped context artifact." }, + { term: "Slot", definition: "" }, + ]); + }); + + it("drops an empty terminology row from a template prefill, leaving no slot behind", () => { + const parsed = parseFrameContent(serializeFramePrefill({ slots: { terminology: [{ term: "", definition: "" }] } })); + expect(parsed.slots.terminology).toBeUndefined(); + }); + + it("drops blank list rows from a template prefill too", () => { + const out = serializeFramePrefill({ + slots: { rules: ["", "Keep it short"], skills: [] }, + }); + const parsed = parseFrameContent(out); + expect(parsed.slots.rules).toEqual(["Keep it short"]); + expect(parsed.slots.skills).toBeUndefined(); + }); }); diff --git a/web/src/lib/frame-yaml.ts b/web/src/lib/frame-yaml.ts index 31f6c56..af4e01c 100644 --- a/web/src/lib/frame-yaml.ts +++ b/web/src/lib/frame-yaml.ts @@ -1,8 +1,15 @@ import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { z } from "zod"; -// Mirrors backend/internal/frames/schema.go. Keep in sync with that file: -// it is the canonical Frame content schema. +// Mirrors backend/internal/frames/schema.go. Keep in sync with that file: it is +// the canonical Frame content schema. +// +// Deliberately lenient about values - a term may decode empty here - because +// this is the shape everything already stored has to parse through, including +// rows written before a rule existed. What a human is allowed to submit is a +// stricter schema over the same keys: contentSlotsSchema in authoring-schema.ts, +// which both the authoring form and the template dialog resolve against. The +// backend splits the same two jobs the same way (Parse vs contentErrors). const termSchema = z.object({ term: z.string(), definition: z.string() }); const slotsSchema = z.object({ @@ -27,7 +34,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(""), @@ -47,14 +59,33 @@ export function parseFrameContent(content: Uint8Array | string): FrameDoc { return frameDocSchema.parse(raw); } +// Rows an author added and left empty. The backend refuses them +// (slots.rules[0]: must not be empty), so keeping them turns an abandoned +// keystroke into a publish failure that names a row nobody wrote - and in a +// template prefill, one that surfaces later in somebody else's scaffold. +// Blank rows in the middle are dropped too: index gaps are not meaningful in +// any of these slots. +function withoutBlanks(items: string[] | undefined): string[] { + return (items ?? []).filter((s) => s.trim() !== ""); +} + +// The same rule for terminology, where a row is a pair. Only a row with +// nothing at all in it is dropped: a half-filled row is a real validation +// error the author meant to write and should see, not a stray keystroke. +function withoutEmptyTerms(terms: FrameDoc["slots"]["terminology"]): { term: string; definition: string }[] { + return (terms ?? []).filter((t) => t.term.trim() !== "" || t.definition.trim() !== ""); +} + // Builds a plain object with keys in schema.go order, omitting empty values, // so the YAML round-trips through the backend Parse (KnownFields(true)). function compactSlots(s: FrameDoc["slots"]): Record { const out: Record = {}; - if (s.terminology && s.terminology.length > 0) out.terminology = s.terminology; - if (s.rules && s.rules.length > 0) out.rules = s.rules; - if (s.skills && s.skills.length > 0) out.skills = s.skills; - if (s.prompts && s.prompts.length > 0) out.prompts = s.prompts; + const terms = withoutEmptyTerms(s.terminology); + if (terms.length > 0) out.terminology = terms; + for (const key of ["rules", "skills", "prompts"] as const) { + const items = withoutBlanks(s[key]); + if (items.length > 0) out[key] = items; + } for (const key of ["tool_specs", "goals", "style", "norms", "architecture", "business_process"] as const) { const v = s[key]; if (typeof v === "string" && v.trim() !== "") out[key] = v; @@ -77,3 +108,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/lib/publish-errors.test.ts b/web/src/lib/publish-errors.test.ts index 9c91289..71c4622 100644 --- a/web/src/lib/publish-errors.test.ts +++ b/web/src/lib/publish-errors.test.ts @@ -4,13 +4,15 @@ import { create } from "@bufbuild/protobuf"; import { FieldViolationsSchema } from "@gen/frames/v1/frame_service_pb"; import { mapPublishError } from "./publish-errors"; -function invalidArgWithViolations(pairs: [string, string][]): ConnectError { +function withViolations(code: Code, message: string, pairs: [string, string][]): ConnectError { const fv = create(FieldViolationsSchema, { - violations: pairs.map(([field, message]) => ({ field, message })), + violations: pairs.map(([field, msg]) => ({ field, message: msg })), }); - return new ConnectError("invalid", Code.InvalidArgument, undefined, [ - { desc: FieldViolationsSchema, value: fv }, - ]); + return new ConnectError(message, code, undefined, [{ desc: FieldViolationsSchema, value: fv }]); +} + +function invalidArgWithViolations(pairs: [string, string][]): ConnectError { + return withViolations(Code.InvalidArgument, "invalid", pairs); } describe("mapPublishError", () => { @@ -26,11 +28,26 @@ describe("mapPublishError", () => { expect(res.formError).toBeNull(); }); - it("maps AlreadyExists to the version field", () => { + it("maps a bare AlreadyExists to the version field", () => { const res = mapPublishError(new ConnectError("frame version already exists", Code.AlreadyExists)); expect(res.fieldErrors["version"]).toMatch(/already exists/); }); + it("maps an AlreadyExists that names a field onto that field", () => { + // A taken frame name and a republished version share this code, and they + // are fixed in different inputs. When the server names the field, that + // wins: routing a name collision to the version input sends the author to + // change the one thing that cannot help. + const res = mapPublishError( + withViolations(Code.AlreadyExists, 'a frame named "brand-voice" already exists; update it instead', [ + ["name", 'a frame named "brand-voice" already exists; update it instead'], + ]), + ); + expect(res.fieldErrors["name"]).toMatch(/already exists/); + expect(res.fieldErrors["version"]).toBeUndefined(); + expect(res.formError).toBeNull(); + }); + it("returns a formError for non-field errors", () => { const res = mapPublishError(new ConnectError("edit permission required", Code.PermissionDenied)); expect(res.formError).toMatch(/permission/i); diff --git a/web/src/lib/publish-errors.ts b/web/src/lib/publish-errors.ts index be9b8b9..4e8b5c6 100644 --- a/web/src/lib/publish-errors.ts +++ b/web/src/lib/publish-errors.ts @@ -6,25 +6,45 @@ export interface PublishErrorResult { formError: string | null; } +// Reads the FieldViolations details the server attaches, mapping each named +// field to its message. Empty when the error carries no detail. +function violations(ce: ConnectError): Record { + const fieldErrors: Record = {}; + for (const fv of ce.findDetails(FieldViolationsSchema)) { + for (const v of fv.violations) { + if (v.field) fieldErrors[v.field] = v.message; + } + } + return fieldErrors; +} + export function mapPublishError(err: unknown): PublishErrorResult { const ce = ConnectError.from(err); - const fieldErrors: Record = {}; if (ce.code === Code.AlreadyExists) { + // Two collisions share this code: a frame name already taken (which a + // publish that asserts creation - anything carrying a template id - now + // reports) and a version already published. They are fixed in different + // inputs, so the server's own field wins when it names one; the version + // input stays the default, which is where a bare republish belongs. + const fieldErrors = violations(ce); + if (Object.keys(fieldErrors).length > 0) { + return { fieldErrors, formError: null }; + } + // No violation means a republished version. A violation carrying an empty + // field name would land here too and be reported as a version conflict, + // which would be wrong - the server never sends one (it names "name"), and + // this is the assumption to revisit if that ever changes. return { fieldErrors: { version: ce.rawMessage || "frame version already exists" }, formError: null }; } if (ce.code === Code.InvalidArgument) { - for (const fv of ce.findDetails(FieldViolationsSchema)) { - for (const v of fv.violations) { - if (v.field) fieldErrors[v.field] = v.message; - } - } + const fieldErrors = violations(ce); if (Object.keys(fieldErrors).length > 0) { return { fieldErrors, formError: null }; } return { fieldErrors, formError: ce.rawMessage || "The frame is invalid." }; } - return { fieldErrors, formError: ce.rawMessage || "Could not publish the frame." }; + return { fieldErrors: {}, formError: ce.rawMessage || "Could not publish the frame." }; } diff --git a/web/src/lib/query-freshness.test.ts b/web/src/lib/query-freshness.test.ts new file mode 100644 index 0000000..4a95df2 --- /dev/null +++ b/web/src/lib/query-freshness.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { ConnectError, Code } from "@connectrpc/connect"; +import { freshData } from "./query-freshness"; + +describe("freshData", () => { + const row = { title: "Our House Style" }; + + it("returns data a fetch delivered during this mount", () => { + expect(freshData({ data: row, error: null, isFetchedAfterMount: true })).toBe(row); + }); + + it("withholds a cached row no fetch has confirmed yet", () => { + // The state on the first render after mount: cached data present, the + // forced refetch still in flight. + expect(freshData({ data: row, error: null, isFetchedAfterMount: false })).toBeUndefined(); + }); + + it("withholds a cached row when the fetch that ran failed", () => { + // query-core counts a failed fetch in isFetchedAfterMount and leaves the + // cached data in place, so this state is indistinguishable from success + // without checking `error`. + expect( + freshData({ + data: row, + error: new ConnectError("connection lost", Code.Unavailable), + isFetchedAfterMount: true, + }), + ).toBeUndefined(); + }); + + it("has nothing to give when a fresh fetch carried nothing", () => { + expect(freshData({ data: undefined, error: null, isFetchedAfterMount: true })).toBeUndefined(); + }); +}); diff --git a/web/src/lib/query-freshness.ts b/web/src/lib/query-freshness.ts new file mode 100644 index 0000000..ac9d03c --- /dev/null +++ b/web/src/lib/query-freshness.ts @@ -0,0 +1,29 @@ +import type { ConnectError } from "@connectrpc/connect"; + +/** + * The data a query fetched during *this* mount, or undefined. + * + * A form that seeds once from server data and then belongs to the author needs + * exactly this, and none of the three conditions is optional: + * + * - React Query returns a cached row synchronously when a query mounts and + * refetches behind it, so `data` alone can be a copy left by an earlier open + * of the same form, or by another page reading the same key. + * - A *failed* fetch also satisfies `isFetchedAfterMount` (it is + * `dataUpdateCount > initial || errorUpdateCount > initial`), and the error + * reducer keeps the cached data rather than clearing it - so "a fetch + * completed" is not "fresh data arrived". + * - `error` clears on the next success, so a form gated on this seeds as soon + * as a retry works. + * + * Pair it with `refetchOnMount: "always"` on the query itself: without that, + * whether a fetch happens at all depends on staleness, which is configured + * globally and elsewhere (`web/src/lib/query.ts`). + */ +export function freshData(query: { + data?: T; + error: ConnectError | null; + isFetchedAfterMount: boolean; +}): T | undefined { + return query.isFetchedAfterMount && !query.error ? query.data : undefined; +} 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/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..94c3416 --- /dev/null +++ b/web/src/pages/AdminTemplatesPage.test.tsx @@ -0,0 +1,359 @@ +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"; +import { Requirement } from "@gen/frames/v1/frame_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"; +import { parseFrameContent } from "@/lib/frame-yaml"; + +const templateListData = { + canManage: true, + templates: [ + { id: "builtin:blank", title: "Blank", description: "Empty.", builtin: true }, + { id: "t1", title: "Our House Style", description: "Ours.", builtin: false }, + ], +}; + +beforeEach(() => { + createMock.mockResolvedValue({ template: { id: "new" } }); + updateMock.mockResolvedValue({ template: { id: "t1" } }); + deleteMock.mockResolvedValue({}); + useQueryMock.mockReturnValue({ + isLoading: false, + error: null, + // The dialog seeds only from a row this mount fetched, so a mock that + // means "fresh data" has to say so. + isFetchedAfterMount: true, + data: templateListData, + }); +}); + +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"); + // The clicked radio is the first "required" on the page, which belongs to + // the terminology section (SLOT_SECTIONS' first entry). Asserting the exact + // slot and level - not just that some rule exists - is what would catch a + // regression that maps every level to the same one, or maps the click to + // the wrong slot key. + expect(arg.fieldRules.terminology).toEqual({ level: Requirement.REQUIRED, note: "" }); +}); + +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(); +}); + +// The row the org template's edit dialog fetches. Carries a suggested parent +// and a field rule so there is something real for an edit to lose: update +// replaces the stored row wholesale, so anything the form does not re-send +// (extends has no editor on this page) must still survive the round trip. +const existingOrgTemplate = { + id: "t1", + title: "Our House Style", + description: "Ours.", + builtin: false, + prefill: new TextEncoder().encode( + "extends:\n - ref: acme/base\n version: 1.0.0\nslots: {}\n", + ), + fieldRules: { terminology: { level: 2, note: "Keep these." } }, +}; + +// Routes by RPC descriptor identity - the same convention as the mock above, +// but reachable per-test so getFrameTemplate can answer with a real row +// instead of falling through to the list fixture every other test relies on. +function mockRowFetch(template: unknown) { + useQueryMock.mockImplementation((method: unknown) => { + if (method === FrameService.method.getFrameTemplate) { + return { isLoading: false, error: null, isFetchedAfterMount: true, data: { template } }; + } + return { isLoading: false, error: null, isFetchedAfterMount: true, data: templateListData }; + }); +} + +it("carries an existing extends suggestion through an edit that only changes the title", async () => { + mockRowFetch(existingOrgTemplate); + renderPage(); + const orgRow = screen.getByText("Our House Style").closest("li, tr")!; + await userEvent.click(orgRow.querySelector('[data-testid="edit-template"]') as HTMLElement); + + const titleInput = await screen.findByLabelText(/^title$/i); + await userEvent.clear(titleInput); + await userEvent.type(titleInput, "Renamed Style"); + await userEvent.click(screen.getByRole("button", { name: /^save changes$/i })); + + await waitFor(() => expect(updateMock).toHaveBeenCalled()); + const arg = updateMock.mock.calls[0][0]; + expect(arg.title).toBe("Renamed Style"); + // The whole point: extends is not editable on this page, so the only way it + // survives an edit (which replaces the stored row wholesale) is by being + // carried through untouched. Decoding the actual bytes sent, rather than + // just checking prefill is non-empty, is what makes this assertion mean + // something. + const sentDoc = parseFrameContent(arg.prefill); + expect(sentDoc.extends).toEqual([{ ref: "acme/base", version: "1.0.0" }]); +}); + +it("seeds the edit form only from a row fetched after the dialog opened", async () => { + // React Query hands a cached row back synchronously when a query mounts and + // refetches in the background (invalidated data stays in the cache, and only + // ACTIVE queries are refetched, so saving does not evict it). A dialog that + // seeded from that copy and then latched would show pre-edit content on a + // second open, and Save would write it back over the first edit. + let fetched = false; + const freshRow = { ...existingOrgTemplate, title: "Renamed Style" }; + useQueryMock.mockImplementation((method: unknown) => { + if (method === FrameService.method.getFrameTemplate) { + return fetched + ? { isLoading: false, error: null, isFetchedAfterMount: true, data: { template: freshRow } } + : { isLoading: false, error: null, isFetchedAfterMount: false, data: { template: existingOrgTemplate } }; + } + return { isLoading: false, error: null, isFetchedAfterMount: true, data: templateListData }; + }); + + const { rerender } = renderPage(); + const orgRow = screen.getByText("Our House Style").closest("li, tr")!; + await userEvent.click(orgRow.querySelector('[data-testid="edit-template"]') as HTMLElement); + + // Still loading as far as this dialog is concerned: the only row available + // is the one that was already in the cache, so no form is shown at all. + expect(screen.queryByLabelText(/^title$/i)).not.toBeInTheDocument(); + + fetched = true; + rerender(); + + await waitFor(() => expect(screen.getByLabelText(/^title$/i)).toHaveValue("Renamed Style")); +}); + +it("says why the edit form is missing when its row cannot be read", async () => { + // The dialog waits for a fetch of its own before seeding, so a failure that + // is not reported would leave the admin looking at a loading skeleton. + const { ConnectError, Code } = await import("@connectrpc/connect"); + useQueryMock.mockImplementation((method: unknown) => { + if (method === FrameService.method.getFrameTemplate) { + return { + isLoading: false, + error: new ConnectError('no template "t1"', Code.NotFound), + isFetchedAfterMount: true, + data: undefined, + }; + } + return { isLoading: false, error: null, isFetchedAfterMount: true, data: templateListData }; + }); + renderPage(); + const orgRow = screen.getByText("Our House Style").closest("li, tr")!; + await userEvent.click(orgRow.querySelector('[data-testid="edit-template"]') as HTMLElement); + + expect(await screen.findByText(/could not be loaded/i)).toBeInTheDocument(); + // A fixed sentence rather than the server's, matching this page's own list + // error and every other read failure in the app. + expect(screen.queryByText(/no template/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/^title$/i)).not.toBeInTheDocument(); +}); + +it("says so when a stored template's content cannot be decoded", async () => { + // Stored bytes that no longer parse (hand-edited row, a format change). + // The seed cannot complete, so the form never renders - which is exactly + // why this message cannot live inside the form. + mockRowFetch({ ...existingOrgTemplate, prefill: new TextEncoder().encode("slots: [not a mapping\n") }); + renderPage(); + const orgRow = screen.getByText("Our House Style").closest("li, tr")!; + await userEvent.click(orgRow.querySelector('[data-testid="edit-template"]') as HTMLElement); + + expect(await screen.findByText(/could not be read/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/^title$/i)).not.toBeInTheDocument(); +}); + +it("keeps an in-progress edit when a later row fetch fails", async () => { + // Same rule as the authoring form: once the dialog has a row and the admin + // is typing, a failed refetch must not replace their unsaved edit with an + // error screen. + const { ConnectError, Code } = await import("@connectrpc/connect"); + let failing = false; + useQueryMock.mockImplementation((method: unknown) => { + if (method === FrameService.method.getFrameTemplate) { + return { + isLoading: false, + isFetchedAfterMount: true, + data: { template: existingOrgTemplate }, + error: failing ? new ConnectError("connection lost", Code.Unavailable) : null, + }; + } + return { isLoading: false, error: null, isFetchedAfterMount: true, data: templateListData }; + }); + + const { rerender } = renderPage(); + const orgRow = screen.getByText("Our House Style").closest("li, tr")!; + await userEvent.click(orgRow.querySelector('[data-testid="edit-template"]') as HTMLElement); + const titleInput = await screen.findByLabelText(/^title$/i); + await userEvent.clear(titleInput); + await userEvent.type(titleInput, "Renamed Style"); + + failing = true; + rerender(); + + expect(screen.getByLabelText(/^title$/i)).toHaveValue("Renamed Style"); + expect(screen.queryByText(/could not be loaded/i)).not.toBeInTheDocument(); +}); + +it("does not seed the edit form from a cached row when the fetch failed, and says which failure it was", async () => { + // Same query-core shape as the authoring page's test: a failed fetch counts + // as "fetched after mount" and leaves the cached row in place. + const { ConnectError, Code } = await import("@connectrpc/connect"); + useQueryMock.mockImplementation((method: unknown) => { + if (method === FrameService.method.getFrameTemplate) { + return { + isLoading: false, + isFetchedAfterMount: true, + error: new ConnectError("connection lost", Code.Unavailable), + data: { template: { ...existingOrgTemplate, title: "Stale Cached Title" } }, + }; + } + return { isLoading: false, error: null, isFetchedAfterMount: true, data: templateListData }; + }); + renderPage(); + const orgRow = screen.getByText("Our House Style").closest("li, tr")!; + await userEvent.click(orgRow.querySelector('[data-testid="edit-template"]') as HTMLElement); + + expect(await screen.findByText(/could not be loaded/i)).toBeInTheDocument(); + expect(screen.queryByDisplayValue("Stale Cached Title")).not.toBeInTheDocument(); + // The failure has to be reported as the failure it was: "no content" is a + // different claim, and asserting the exact sentence is what stops this test + // from passing whichever one appears. + expect(screen.getByText(/may have been deleted, or the registry could not be reached/i)).toBeInTheDocument(); + expect(screen.queryByText(/returned no content/i)).not.toBeInTheDocument(); +}); + +it("refuses to save a template whose terminology row is incomplete", async () => { + // Client-side, per row, rather than a server round trip: serialization drops + // rows that are entirely empty, so an incomplete row reported by the server + // can carry an index that no longer matches the form. The authoring form has + // always held content to these rules; the template dialog now does too. + // beforeEach re-stubs the mutation mocks but does not clear their call + // history, so this test counts only its own calls. + createMock.mockClear(); + 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: /add term/i })); + // Term filled, definition left blank: a row the admin meant to write and + // did not finish, which is exactly the row that must not be dropped + // silently and must not travel to the server either. + await userEvent.type(screen.getByPlaceholderText("Term"), "Frame"); + + await userEvent.click(screen.getByRole("button", { name: /^create$/i })); + + expect(await screen.findByText(/must not be empty/i)).toBeInTheDocument(); + expect(createMock).not.toHaveBeenCalled(); +}); + +it("shows why a duplicate term blocks saving instead of doing nothing", async () => { + // An array-level rule (the duplicate-term refine) is nested by + // @hookform/resolvers under `slots.terminology.root` rather than + // `slots.terminology`, because the path has registered children. A reader + // that only looks at `.message` finds nothing, so the form refuses to submit + // and says nothing about why - a Save button that does nothing at all. + createMock.mockClear(); + 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: /add term/i })); + await userEvent.type(screen.getAllByPlaceholderText("Term")[0], "Frame"); + await userEvent.type(screen.getAllByPlaceholderText("Definition")[0], "One."); + await userEvent.click(screen.getByRole("button", { name: /add term/i })); + await userEvent.type(screen.getAllByPlaceholderText("Term")[1], "Frame"); + await userEvent.type(screen.getAllByPlaceholderText("Definition")[1], "Two."); + + await userEvent.click(screen.getByRole("button", { name: /^create$/i })); + + expect(await screen.findByText(/duplicate term/i)).toBeInTheDocument(); + expect(createMock).not.toHaveBeenCalled(); +}); diff --git a/web/src/pages/AdminTemplatesPage.tsx b/web/src/pages/AdminTemplatesPage.tsx new file mode 100644 index 0000000..eb558cc --- /dev/null +++ b/web/src/pages/AdminTemplatesPage.tsx @@ -0,0 +1,454 @@ +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 { contentSlotsSchema } from "@/lib/authoring-schema"; +import { freshData } from "@/lib/query-freshness"; +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, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle } from "@/components/ui/dialog"; + +const encode = (s: string) => new TextEncoder().encode(s); + +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"), + // The same content rules the authoring form applies, not frame-yaml's decode + // schema: a blank row an admin abandoned would otherwise reach the server, + // come back as a flattened message, and - since serialization drops empty + // rows - name a row index that no longer matches what is on screen. + slots: contentSlotsSchema, + 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 ( +
+ +