diff --git a/README.md b/README.md
index 4477369..ed0e9ab 100644
--- a/README.md
+++ b/README.md
@@ -560,6 +560,56 @@ Create or replace a JSON Schema. Authentication required.
| `400` | Invalid JSON or invalid JSON Schema |
| `401` | Missing or invalid Bearer token |
+**Example:**
+
+```bash
+curl -s -X PUT http://127.0.0.1:8080/schema/product.v1 \
+ -H "Authorization: Bearer $TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "product.v1",
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "price": {"type": "number", "minimum": 0}
+ },
+ "required": ["name", "price"]
+ }'
+```
+
+---
+
+### `DELETE /schema/{schemaID}`
+
+Permanently delete a JSON Schema. Requires `write` permission.
+
+Existing entities stored under that schema ID are **not** affected. Future entity
+writes will proceed without validation until a new template is uploaded.
+
+**Path parameter:** `schemaID` — the schema identifier (e.g., `chat.v1`).
+
+**Response:**
+
+```json
+{ "status": "ok" }
+```
+
+**Error responses:**
+
+| HTTP code | Meaning |
+|-----------|---------|
+| `401` | Missing or invalid Bearer token |
+| `403` | Token lacks `write` permission |
+| `404` | Schema not found |
+
+**Example:**
+
+```bash
+curl -s -X DELETE http://127.0.0.1:8080/schema/product.v1 \
+ -H "Authorization: Bearer $TOKEN"
+```
+
---
## JSON Schema Templates
diff --git a/api/openapi.yaml b/api/openapi.yaml
index adc51e5..d9b8d69 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -661,6 +661,56 @@ paths:
schema:
$ref: "#/components/schemas/Error"
+ delete:
+ operationId: deleteSchema
+ summary: Delete a JSON Schema
+ description: |
+ Permanently delete a JSON Schema document by its ID. Requires `write`
+ permission.
+
+ Once deleted, entities that were previously validated against this schema
+ are unaffected (they remain stored), but future `PUT` requests to the
+ same `schema_id` will no longer be validated until a new schema is
+ uploaded. To update a schema instead of deleting it, use `PUT`.
+
+ Schema IDs must not contain `/`, `\`, or `..` sequences.
+ tags: [schemas]
+ security:
+ - BearerAuth: []
+ parameters:
+ - name: schemaID
+ in: path
+ required: true
+ description: Schema identifier (e.g., `product.v1`).
+ schema:
+ type: string
+ example: product.v1
+ responses:
+ "200":
+ description: Schema deleted successfully.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/StatusOK"
+ "401":
+ description: Missing or invalid Bearer token.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "403":
+ description: Token lacks `write` permission.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "404":
+ description: Schema not found.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+
# ---------------------------------------------------------------------------
# API Keys
# ---------------------------------------------------------------------------
diff --git a/api/proto/worker.proto b/api/proto/worker.proto
index a83442d..0170733 100644
--- a/api/proto/worker.proto
+++ b/api/proto/worker.proto
@@ -29,7 +29,9 @@ message ProcessRequest {
string database_name = 1;
string entity_key = 2;
string schema_id = 3;
- string operation = 4; // GET, PUT, or DELETE
+ // Entity operations: GET, PUT, DELETE
+ // Schema template operations: SCHEMA_GET, SCHEMA_PUT, SCHEMA_DELETE
+ string operation = 4;
bytes payload = 5;
string token = 6;
}
diff --git a/cmd/main-worker/server.go b/cmd/main-worker/server.go
index 383bdf0..b7922df 100644
--- a/cmd/main-worker/server.go
+++ b/cmd/main-worker/server.go
@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
+ "errors"
"fmt"
"log"
"net"
@@ -318,11 +319,63 @@ func (s *MainWorkerServer) Process(ctx context.Context, req *proto.ProcessReques
return nil, status.Error(codes.Unauthenticated, "invalid or expired token")
}
+ // ── Schema management operations ──────────────────────────────────────
+ // SCHEMA_GET / SCHEMA_PUT / SCHEMA_DELETE operate on the JSON Schema
+ // template registry, not on entity data.
+ switch op {
+ case "SCHEMA_GET":
+ if s.validator == nil {
+ s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc()
+ return nil, status.Error(codes.Unavailable, "schema management unavailable")
+ }
+ data, err := s.validator.GetTemplateData(req.GetSchemaId())
+ if err != nil {
+ s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc()
+ s.metrics.ProcessDurationSeconds.WithLabelValues(op, "error").Observe(time.Since(start).Seconds())
+ return nil, status.Error(codes.NotFound, "schema not found")
+ }
+ s.metrics.ProcessRequestsTotal.WithLabelValues(op, "success").Inc()
+ s.metrics.ProcessDurationSeconds.WithLabelValues(op, "success").Observe(time.Since(start).Seconds())
+ return &proto.ProcessResponse{Status: "OK", Result: data}, nil
+
+ case "SCHEMA_PUT":
+ if s.validator == nil {
+ s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc()
+ return nil, status.Error(codes.Unavailable, "schema management unavailable")
+ }
+ if err := s.validator.SaveTemplate(req.GetSchemaId(), req.GetPayload()); err != nil {
+ s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc()
+ s.metrics.ProcessDurationSeconds.WithLabelValues(op, "error").Observe(time.Since(start).Seconds())
+ return nil, status.Error(codes.InvalidArgument, err.Error())
+ }
+ s.metrics.ProcessRequestsTotal.WithLabelValues(op, "success").Inc()
+ s.metrics.ProcessDurationSeconds.WithLabelValues(op, "success").Observe(time.Since(start).Seconds())
+ return &proto.ProcessResponse{Status: "OK"}, nil
+
+ case "SCHEMA_DELETE":
+ if s.validator == nil {
+ s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc()
+ return nil, status.Error(codes.Unavailable, "schema management unavailable")
+ }
+ if err := s.validator.DeleteTemplate(req.GetSchemaId()); err != nil {
+ s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc()
+ s.metrics.ProcessDurationSeconds.WithLabelValues(op, "error").Observe(time.Since(start).Seconds())
+ if errors.Is(err, schema.ErrSchemaNotFound) {
+ return nil, status.Error(codes.NotFound, err.Error())
+ }
+ return nil, status.Error(codes.Internal, err.Error())
+ }
+ s.metrics.ProcessRequestsTotal.WithLabelValues(op, "success").Inc()
+ s.metrics.ProcessDurationSeconds.WithLabelValues(op, "success").Observe(time.Since(start).Seconds())
+ return &proto.ProcessResponse{Status: "OK"}, nil
+ }
+
+ // ── Entity operations ─────────────────────────────────────────────────
// Validate operation
if op != "GET" && op != "PUT" && op != "DELETE" {
s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc()
s.metrics.ProcessDurationSeconds.WithLabelValues(op, "error").Observe(time.Since(start).Seconds())
- return nil, status.Error(codes.InvalidArgument, "operation must be GET, PUT, or DELETE")
+ return nil, status.Error(codes.InvalidArgument, "operation must be GET, PUT, DELETE, SCHEMA_GET, SCHEMA_PUT, or SCHEMA_DELETE")
}
if op == "GET" {
@@ -779,10 +832,11 @@ func (s *MainWorkerServer) handleAdminSchemas(w http.ResponseWriter, r *http.Req
json.NewEncoder(w).Encode(schemas) //nolint:errcheck
}
-// handleSchema serves GET and PUT requests for /schema/{id}.
+// handleSchema serves GET, PUT, and DELETE requests for /schema/{id}.
//
-// GET /schema/{id} — retrieve a schema JSON (no authentication required).
-// PUT /schema/{id} — create or replace a schema (authentication required).
+// GET /schema/{id} — retrieve a schema JSON (no authentication required).
+// PUT /schema/{id} — create or replace a schema (write permission required).
+// DELETE /schema/{id} — permanently delete a schema (write permission required).
func (s *MainWorkerServer) handleSchema(w http.ResponseWriter, r *http.Request) {
schemaID := strings.TrimPrefix(r.URL.Path, "/schema/")
// Reject empty or path-traversal schema IDs.
@@ -825,6 +879,21 @@ func (s *MainWorkerServer) handleSchema(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) //nolint:errcheck
+ case http.MethodDelete:
+ if !s.requirePermission(w, r, auth.PermWrite) {
+ return
+ }
+ if err := s.validator.DeleteTemplate(schemaID); err != nil {
+ if errors.Is(err, schema.ErrSchemaNotFound) {
+ http.Error(w, `{"error":"not_found"}`, http.StatusNotFound)
+ } else {
+ http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusInternalServerError)
+ }
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) //nolint:errcheck
+
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
diff --git a/cmd/main-worker/server_test.go b/cmd/main-worker/server_test.go
index 0ec35a5..0968d8b 100644
--- a/cmd/main-worker/server_test.go
+++ b/cmd/main-worker/server_test.go
@@ -486,6 +486,198 @@ func TestHandleSchema(t *testing.T) {
})
}
+func TestHandleSchemaDelete(t *testing.T) {
+ config := createTestConfigWithTempDir(t)
+ server, err := NewMainWorkerServer(config)
+ require.NoError(t, err)
+ require.NotNil(t, server.validator)
+
+ ct, err := server.tokenManager.GenerateClientToken("test-client", []string{"read", "write"})
+ require.NoError(t, err)
+ authHeader := "Bearer " + ct.Token
+
+ schemaJSON := `{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}`
+
+ // Pre-populate a schema to delete in these tests.
+ putReq := httptest.NewRequest(http.MethodPut, "/schema/todelete.v1", strings.NewReader(schemaJSON))
+ putReq.Header.Set("Authorization", authHeader)
+ putW := httptest.NewRecorder()
+ server.handleSchema(putW, putReq)
+ require.Equal(t, http.StatusOK, putW.Code)
+
+ t.Run("DELETE requires Authorization header", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodDelete, "/schema/todelete.v1", nil)
+ w := httptest.NewRecorder()
+ server.handleSchema(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+ })
+
+ t.Run("DELETE with write token removes schema", func(t *testing.T) {
+ // Re-create schema so we can delete it.
+ putReq2 := httptest.NewRequest(http.MethodPut, "/schema/todelete.v1", strings.NewReader(schemaJSON))
+ putReq2.Header.Set("Authorization", authHeader)
+ putW2 := httptest.NewRecorder()
+ server.handleSchema(putW2, putReq2)
+ require.Equal(t, http.StatusOK, putW2.Code)
+
+ req := httptest.NewRequest(http.MethodDelete, "/schema/todelete.v1", nil)
+ req.Header.Set("Authorization", authHeader)
+ w := httptest.NewRecorder()
+ server.handleSchema(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var resp map[string]string
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
+ assert.Equal(t, "ok", resp["status"])
+ })
+
+ t.Run("GET returns 404 after DELETE", func(t *testing.T) {
+ // Ensure schema is gone.
+ req := httptest.NewRequest(http.MethodGet, "/schema/todelete.v1", nil)
+ w := httptest.NewRecorder()
+ server.handleSchema(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+ })
+
+ t.Run("DELETE non-existent schema returns 404", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodDelete, "/schema/ghost.v99", nil)
+ req.Header.Set("Authorization", authHeader)
+ w := httptest.NewRecorder()
+ server.handleSchema(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+ })
+
+ t.Run("DELETE with read-only token is forbidden", func(t *testing.T) {
+ readCT, err := server.tokenManager.GenerateClientToken("reader", []string{"read"})
+ require.NoError(t, err)
+
+ req := httptest.NewRequest(http.MethodDelete, "/schema/todelete.v1", nil)
+ req.Header.Set("Authorization", "Bearer "+readCT.Token)
+ w := httptest.NewRecorder()
+ server.handleSchema(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ })
+
+ t.Run("DELETE rejects path traversal schema id", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodDelete, "/schema/../etc/passwd", nil)
+ req.Header.Set("Authorization", authHeader)
+ w := httptest.NewRecorder()
+ server.handleSchema(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ })
+
+ t.Run("admin schemas list excludes deleted schema", func(t *testing.T) {
+ // Put a schema, then delete it, then verify it is absent.
+ putReq3 := httptest.NewRequest(http.MethodPut, "/schema/listed.v1", strings.NewReader(schemaJSON))
+ putReq3.Header.Set("Authorization", authHeader)
+ putW3 := httptest.NewRecorder()
+ server.handleSchema(putW3, putReq3)
+ require.Equal(t, http.StatusOK, putW3.Code)
+
+ delReq := httptest.NewRequest(http.MethodDelete, "/schema/listed.v1", nil)
+ delReq.Header.Set("Authorization", authHeader)
+ delW := httptest.NewRecorder()
+ server.handleSchema(delW, delReq)
+ require.Equal(t, http.StatusOK, delW.Code)
+
+ listReq := httptest.NewRequest(http.MethodGet, "/admin/schemas", nil)
+ listW := httptest.NewRecorder()
+ server.handleAdminSchemas(listW, listReq)
+ require.Equal(t, http.StatusOK, listW.Code)
+ var schemas []string
+ require.NoError(t, json.Unmarshal(listW.Body.Bytes(), &schemas))
+ assert.NotContains(t, schemas, "listed.v1")
+ })
+}
+
+func TestProcessSchemaOperations(t *testing.T) {
+ config := createTestConfigWithTempDir(t)
+ server, err := NewMainWorkerServer(config)
+ require.NoError(t, err)
+ require.NotNil(t, server.validator)
+
+ // Get a valid worker token for Process calls.
+ _, pubKey, err := crypto.GenerateRSAKeyPair(2048)
+ require.NoError(t, err)
+ pubPEM, err := crypto.MarshalPublicKeyToPEM(pubKey)
+ require.NoError(t, err)
+ subResp, err := server.Subscribe(context.Background(), &proto.SubscribeRequest{
+ WorkerId: "test-worker",
+ Pubkey: pubPEM,
+ })
+ require.NoError(t, err)
+ workerToken := subResp.Token
+
+ schemaJSON := []byte(`{"type":"object","properties":{"value":{"type":"number"}},"required":["value"]}`)
+
+ t.Run("SCHEMA_PUT saves schema", func(t *testing.T) {
+ resp, err := server.Process(context.Background(), &proto.ProcessRequest{
+ Operation: "SCHEMA_PUT",
+ SchemaId: "grpc.v1",
+ Payload: schemaJSON,
+ Token: workerToken,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "OK", resp.Status)
+ })
+
+ t.Run("SCHEMA_GET retrieves saved schema", func(t *testing.T) {
+ resp, err := server.Process(context.Background(), &proto.ProcessRequest{
+ Operation: "SCHEMA_GET",
+ SchemaId: "grpc.v1",
+ Token: workerToken,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "OK", resp.Status)
+ assert.NotEmpty(t, resp.Result)
+ })
+
+ t.Run("SCHEMA_DELETE removes schema", func(t *testing.T) {
+ resp, err := server.Process(context.Background(), &proto.ProcessRequest{
+ Operation: "SCHEMA_DELETE",
+ SchemaId: "grpc.v1",
+ Token: workerToken,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "OK", resp.Status)
+ })
+
+ t.Run("SCHEMA_GET returns NotFound after delete", func(t *testing.T) {
+ _, err := server.Process(context.Background(), &proto.ProcessRequest{
+ Operation: "SCHEMA_GET",
+ SchemaId: "grpc.v1",
+ Token: workerToken,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "NotFound")
+ })
+
+ t.Run("SCHEMA_DELETE non-existent schema returns NotFound", func(t *testing.T) {
+ _, err := server.Process(context.Background(), &proto.ProcessRequest{
+ Operation: "SCHEMA_DELETE",
+ SchemaId: "ghost.v99",
+ Token: workerToken,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "NotFound")
+ })
+
+ t.Run("unknown operation returns InvalidArgument", func(t *testing.T) {
+ _, err := server.Process(context.Background(), &proto.ProcessRequest{
+ Operation: "BOGUS",
+ SchemaId: "any",
+ Token: workerToken,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "InvalidArgument")
+ })
+}
+
func TestConcurrentSubscriptions(t *testing.T) {
config := createTestConfig()
server, err := NewMainWorkerServer(config)
diff --git a/cmd/main-worker/static/app.html b/cmd/main-worker/static/app.html
index 15a8209..d5cef40 100644
--- a/cmd/main-worker/static/app.html
+++ b/cmd/main-worker/static/app.html
@@ -277,6 +277,7 @@
+
@@ -728,9 +729,12 @@
wrap.innerHTML = 'No templates yet
';
} else {
wrap.innerHTML = schemas.map(s => `
- `).join('');
+
+
+
+
`).join('');
}
}
} catch {}
@@ -769,6 +773,37 @@
} catch { showAlert(alertEl, 'error', 'Network error.'); }
}
+ async function deleteSchema(id) {
+ if (!id) return;
+ if (!confirm('Permanently delete schema "' + id + '"?\n\nExisting entities are not affected.')) return;
+ const alertEl = document.getElementById('schemaAlert');
+ alertEl.classList.add('hidden');
+ try {
+ const r = await api('/schema/' + encodeURIComponent(id), { method: 'DELETE' });
+ if (r.ok) {
+ // Clear editor if it was showing the deleted schema.
+ if (document.getElementById('schemaId').value === id) {
+ document.getElementById('schemaId').value = '';
+ document.getElementById('schemaBody').value = '';
+ }
+ showAlert(alertEl, 'success', 'Schema "' + id + '" deleted.');
+ await loadTemplates();
+ } else {
+ const d = await r.json().catch(() => ({}));
+ showAlert(alertEl, 'error', d.error || 'Delete failed.');
+ }
+ } catch { showAlert(alertEl, 'error', 'Network error.'); }
+ }
+
+ function deleteSchemaById() {
+ const id = document.getElementById('schemaId').value.trim();
+ if (!id) {
+ showAlert(document.getElementById('schemaAlert'), 'error', 'Schema ID required.');
+ return;
+ }
+ deleteSchema(id);
+ }
+
function exportPydantic() {
const alertEl = document.getElementById('schemaAlert');
let schema;
diff --git a/cmd/main-worker/static/css/delta.css b/cmd/main-worker/static/css/delta.css
index 19bc6b7..97f57bb 100644
--- a/cmd/main-worker/static/css/delta.css
+++ b/cmd/main-worker/static/css/delta.css
@@ -370,6 +370,15 @@ input::placeholder, textarea::placeholder { color: var(--c-text-faint); }
}
.schema-list-item:hover { background: rgba(100,116,139,.1); color: var(--c-text); }
.schema-list-item.active { background: rgba(56,189,248,.1); color: var(--c-accent-light); }
+.schema-list-row { display: flex; align-items: center; gap: .375rem; }
+.schema-list-btn {
+ flex: 1; display: flex; justify-content: space-between;
+ text-align: left; background: none; border: none;
+ padding: .375rem .25rem; cursor: pointer; color: inherit;
+ font-size: .825rem; font-family: monospace; color: var(--c-text-muted);
+}
+.schema-list-btn:hover { color: var(--c-text); }
+.muted-text { color: #64748b; margin-left: auto; }
/* ── Explorer ──────────────────────────────────────────────────────── */
.quick-btns { display: flex; flex-wrap: wrap; gap: .375rem; }
diff --git a/docs/usage/api-reference.md b/docs/usage/api-reference.md
index 72fe5b3..4c162e6 100644
--- a/docs/usage/api-reference.md
+++ b/docs/usage/api-reference.md
@@ -276,6 +276,105 @@ Content-Type: application/json
| `401` | Missing or invalid Bearer token |
| `403` | Token lacks `write` permission |
+**Example:**
+
+```bash
+curl -s -X PUT http://127.0.0.1:8080/schema/product.v1 \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "product.v1",
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "price": {"type": "number", "minimum": 0}
+ },
+ "required": ["name", "price"]
+ }'
+```
+
+---
+
+### `DELETE /schema/{schemaID}`
+
+Permanently delete a JSON Schema. Requires `write` permission.
+
+Once deleted, entities that were previously validated against this schema are
+unaffected (they remain stored), but future `PUT /entity/{schema_id}` requests
+will no longer be validated until a new schema is uploaded. To update a schema
+instead, use `PUT /schema/{schemaID}`.
+
+**Path parameter:** `schemaID` — the schema identifier.
+
+**Request:**
+
+```http
+DELETE /schema/product.v1
+Authorization: Bearer
+```
+
+**Response `200 OK`:**
+
+```json
+{"status": "ok"}
+```
+
+**Error responses:**
+
+| Code | Meaning |
+|------|---------|
+| `401` | Missing or invalid Bearer token |
+| `403` | Token lacks `write` permission |
+| `404` | Schema not found |
+
+**Example:**
+
+```bash
+curl -s -X DELETE http://127.0.0.1:8080/schema/product.v1 \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+---
+
+### gRPC Schema Operations
+
+All three schema operations are also available via the gRPC `Process` RPC.
+Use the `operation` field of `ProcessRequest` with one of the schema-specific
+operation strings:
+
+| Operation | Equivalent REST | `schema_id` | `payload` |
+|-----------|-----------------|-------------|-----------|
+| `SCHEMA_GET` | `GET /schema/{id}` | schema identifier | — |
+| `SCHEMA_PUT` | `PUT /schema/{id}` | schema identifier | JSON Schema bytes |
+| `SCHEMA_DELETE` | `DELETE /schema/{id}` | schema identifier | — |
+
+**Example (Go pseudo-code):**
+
+```go
+// Retrieve a schema via gRPC
+resp, err := client.Process(ctx, &proto.ProcessRequest{
+ Operation: "SCHEMA_GET",
+ SchemaId: "product.v1",
+ Token: workerToken,
+})
+
+// Create / update a schema via gRPC
+resp, err := client.Process(ctx, &proto.ProcessRequest{
+ Operation: "SCHEMA_PUT",
+ SchemaId: "product.v1",
+ Payload: schemaJSON,
+ Token: workerToken,
+})
+
+// Delete a schema via gRPC
+resp, err := client.Process(ctx, &proto.ProcessRequest{
+ Operation: "SCHEMA_DELETE",
+ SchemaId: "product.v1",
+ Token: workerToken,
+})
+```
+
---
## API Keys
diff --git a/docs/usage/schemas.md b/docs/usage/schemas.md
index 857bfdd..c67ec37 100644
--- a/docs/usage/schemas.md
+++ b/docs/usage/schemas.md
@@ -50,7 +50,7 @@ curl -s -X PUT http://127.0.0.1:8080/schema/chat.v1 \
### Via the Web UI
-Open **http://localhost:8080/** → **Schemas** tab → **New Schema** and paste your JSON Schema document.
+Open **http://localhost:8080/** → **Templates** tab → type a Schema ID, enter your JSON Schema, and click **Save**.
### Directly on Disk
@@ -74,6 +74,76 @@ The worker picks up the file automatically — no restart required.
---
+## Updating a Schema
+
+Use `PUT /schema/{schemaID}` to replace an existing schema with a new definition.
+The old schema is atomically overwritten; the in-memory cache is immediately
+refreshed. Requires `write` permission.
+
+```bash
+# Update chat.v1 to also require a session_id field
+curl -s -X PUT http://127.0.0.1:8080/schema/chat.v1 \
+ -H "Authorization: Bearer $TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chat.v1",
+ "type": "object",
+ "properties": {
+ "session_id": {"type": "string"},
+ "messages": {"type": "array"}
+ },
+ "required": ["session_id", "messages"]
+ }'
+```
+
+> **Note:** Updating a schema does **not** re-validate existing stored entities.
+> Only future `PUT /entity/{schema_id}` requests are validated against the new
+> definition. Consider bumping the version (e.g., `chat.v2`) for breaking changes.
+
+**Via gRPC (`SCHEMA_PUT`):**
+
+```go
+resp, err := client.Process(ctx, &proto.ProcessRequest{
+ Operation: "SCHEMA_PUT",
+ SchemaId: "chat.v1",
+ Payload: updatedSchemaJSON,
+ Token: workerToken,
+})
+```
+
+---
+
+## Deleting a Schema
+
+Use `DELETE /schema/{schemaID}` to permanently remove a schema template.
+Requires `write` permission.
+
+```bash
+curl -s -X DELETE http://127.0.0.1:8080/schema/chat.v1 \
+ -H "Authorization: Bearer $TOKEN"
+# → {"status":"ok"}
+```
+
+> **Note:** Deleting a schema does **not** delete the entities stored under that
+> schema ID. Existing data is unaffected. Future entity writes to the same
+> `schema_id` will succeed without validation until a new template is uploaded.
+
+**Via the Web UI:** open the **Templates** tab, click the schema you want to
+remove from the list, and click the **Delete** button.
+
+**Via gRPC (`SCHEMA_DELETE`):**
+
+```go
+resp, err := client.Process(ctx, &proto.ProcessRequest{
+ Operation: "SCHEMA_DELETE",
+ SchemaId: "chat.v1",
+ Token: workerToken,
+})
+```
+
+---
+
## Listing and Retrieving Schemas
```bash
@@ -84,6 +154,17 @@ curl http://127.0.0.1:8080/admin/schemas
curl http://127.0.0.1:8080/schema/chat.v1
```
+**Via gRPC (`SCHEMA_GET`):**
+
+```go
+resp, err := client.Process(ctx, &proto.ProcessRequest{
+ Operation: "SCHEMA_GET",
+ SchemaId: "chat.v1",
+ Token: workerToken,
+})
+// resp.Result contains the raw JSON Schema bytes
+```
+
---
## Schema Naming Convention
diff --git a/pkg/schema/validator.go b/pkg/schema/validator.go
index ba598a4..eb91269 100644
--- a/pkg/schema/validator.go
+++ b/pkg/schema/validator.go
@@ -2,6 +2,7 @@ package schema
import (
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -11,6 +12,9 @@ import (
"github.com/xeipuuv/gojsonschema"
)
+// ErrSchemaNotFound is returned when a requested schema template does not exist.
+var ErrSchemaNotFound = errors.New("schema template not found")
+
// isValidSchemaID returns true when id is safe to use as a filename component.
// It rejects empty strings, path separators (/ and \), and any string
// containing ".." to prevent directory-traversal attacks.
@@ -84,7 +88,7 @@ func (v *Validator) LoadTemplate(schemaID string) error {
// Check if template file exists
if _, err := os.Stat(templatePath); os.IsNotExist(err) {
- return fmt.Errorf("schema template not found: %s", schemaID)
+ return fmt.Errorf("%w: %s", ErrSchemaNotFound, schemaID)
}
// Read template file
@@ -245,6 +249,31 @@ func (v *Validator) GetTemplateData(schemaID string) ([]byte, error) {
return os.ReadFile(templatePath)
}
+// DeleteTemplate removes a schema template from disk and the in-memory cache.
+// Returns an error if the schema ID is invalid or the template does not exist.
+func (v *Validator) DeleteTemplate(schemaID string) error {
+ if !isValidSchemaID(schemaID) {
+ return fmt.Errorf("invalid schema ID")
+ }
+
+ templatePath := filepath.Join(v.templatesPath, schemaID+".json")
+
+ // Remove from disk first; return a recognisable error if it is absent.
+ if _, err := os.Stat(templatePath); os.IsNotExist(err) {
+ return fmt.Errorf("%w: %s", ErrSchemaNotFound, schemaID)
+ }
+ if err := os.Remove(templatePath); err != nil {
+ return fmt.Errorf("failed to delete template file: %w", err)
+ }
+
+ // Evict from the in-memory cache so stale data is never served.
+ v.mu.Lock()
+ delete(v.schemas, schemaID)
+ v.mu.Unlock()
+
+ return nil
+}
+
// SaveTemplate saves a schema template to disk.
// This is useful for creating or updating templates programmatically.
func (v *Validator) SaveTemplate(schemaID string, schemaData []byte) error {
diff --git a/pkg/schema/validator_test.go b/pkg/schema/validator_test.go
index 05b0997..b7e1e55 100644
--- a/pkg/schema/validator_test.go
+++ b/pkg/schema/validator_test.go
@@ -487,6 +487,66 @@ func TestSaveTemplate(t *testing.T) {
})
}
+func TestDeleteTemplate(t *testing.T) {
+ validator, templatesPath := setupTestValidator(t)
+
+ t.Run("deletes existing template from disk and cache", func(t *testing.T) {
+ schemaData := createUserSchema()
+ require.NoError(t, validator.SaveTemplate("user.del.v1", schemaData))
+
+ // Confirm it exists on disk and in cache.
+ templatePath := filepath.Join(templatesPath, "user.del.v1.json")
+ assert.FileExists(t, templatePath)
+ assert.Contains(t, validator.GetLoadedSchemas(), "user.del.v1")
+
+ err := validator.DeleteTemplate("user.del.v1")
+ assert.NoError(t, err)
+
+ // File must be gone.
+ assert.NoFileExists(t, templatePath)
+ // Cache must be cleared.
+ assert.NotContains(t, validator.GetLoadedSchemas(), "user.del.v1")
+ })
+
+ t.Run("returns error for non-existent template", func(t *testing.T) {
+ err := validator.DeleteTemplate("ghost.v99")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+ })
+
+ t.Run("returns error for empty schema ID", func(t *testing.T) {
+ err := validator.DeleteTemplate("")
+ assert.Error(t, err)
+ })
+
+ t.Run("returns error for path traversal schema ID", func(t *testing.T) {
+ err := validator.DeleteTemplate("../etc/passwd")
+ assert.Error(t, err)
+ })
+
+ t.Run("deleted schema is absent from ListAvailableTemplates", func(t *testing.T) {
+ require.NoError(t, validator.SaveTemplate("listed.del.v1", createUserSchema()))
+
+ listed, err := validator.ListAvailableTemplates()
+ require.NoError(t, err)
+ assert.Contains(t, listed, "listed.del.v1")
+
+ require.NoError(t, validator.DeleteTemplate("listed.del.v1"))
+
+ listed2, err := validator.ListAvailableTemplates()
+ require.NoError(t, err)
+ assert.NotContains(t, listed2, "listed.del.v1")
+ })
+
+ t.Run("validate returns error after template deleted", func(t *testing.T) {
+ require.NoError(t, validator.SaveTemplate("validate.del.v1", createUserSchema()))
+ require.NoError(t, validator.DeleteTemplate("validate.del.v1"))
+
+ _, err := validator.Validate("validate.del.v1", []byte(`{"id":"1","email":"a@b.com"}`))
+ assert.Error(t, err)
+ })
+}
+
func TestChatSchemaValidation(t *testing.T) {
validator, templatesPath := setupTestValidator(t)
diff --git a/tests/test_schema_api.py b/tests/test_schema_api.py
index c30370f..44028c8 100644
--- a/tests/test_schema_api.py
+++ b/tests/test_schema_api.py
@@ -217,3 +217,104 @@ def test_put_schema_roundtrip(live_main_worker_schemas):
get_r = requests.get(base + "/schema/roundtrip.v1", timeout=5)
assert get_r.status_code == 200
assert get_r.json()["properties"]["score"]["type"] == "number"
+
+
+def test_delete_schema_requires_auth(live_main_worker_schemas):
+ """DELETE /schema/{id} without a Bearer token must return 401."""
+ base = live_main_worker_schemas["rest_url"]
+ headers = {"Authorization": f"Bearer {live_main_worker_schemas['token']}"}
+
+ # Create the schema first so we know it exists.
+ requests.put(base + "/schema/delete_auth_test.v1",
+ json={"type": "object"}, headers=headers, timeout=5)
+
+ r = requests.delete(base + "/schema/delete_auth_test.v1", timeout=5)
+ assert r.status_code == 401
+
+
+def test_delete_schema_with_auth_succeeds(live_main_worker_schemas):
+ """DELETE /schema/{id} with a valid Bearer token must return 200."""
+ base = live_main_worker_schemas["rest_url"]
+ headers = {"Authorization": f"Bearer {live_main_worker_schemas['token']}"}
+ schema_id = "to_delete.v1"
+
+ # Create it first.
+ put_r = requests.put(base + f"/schema/{schema_id}",
+ json={"type": "object"}, headers=headers, timeout=5)
+ assert put_r.status_code == 200
+
+ # Now delete it.
+ del_r = requests.delete(base + f"/schema/{schema_id}",
+ headers=headers, timeout=5)
+ assert del_r.status_code == 200
+ assert del_r.json().get("status") == "ok"
+
+
+def test_get_schema_returns_404_after_delete(live_main_worker_schemas):
+ """GET /schema/{id} must return 404 after the schema is deleted."""
+ base = live_main_worker_schemas["rest_url"]
+ headers = {"Authorization": f"Bearer {live_main_worker_schemas['token']}"}
+ schema_id = "get_after_delete.v1"
+
+ requests.put(base + f"/schema/{schema_id}",
+ json={"type": "object"}, headers=headers, timeout=5)
+ requests.delete(base + f"/schema/{schema_id}",
+ headers=headers, timeout=5)
+
+ r = requests.get(base + f"/schema/{schema_id}", timeout=5)
+ assert r.status_code == 404
+
+
+def test_delete_schema_not_found(live_main_worker_schemas):
+ """DELETE /schema/{id} for a non-existent schema must return 404."""
+ base = live_main_worker_schemas["rest_url"]
+ headers = {"Authorization": f"Bearer {live_main_worker_schemas['token']}"}
+ r = requests.delete(base + "/schema/does-not-exist-at-all.v99",
+ headers=headers, timeout=5)
+ assert r.status_code == 404
+
+
+def test_admin_schemas_excludes_deleted_schema(live_main_worker_schemas):
+ """After DELETE, /admin/schemas must no longer include the schema ID."""
+ base = live_main_worker_schemas["rest_url"]
+ headers = {"Authorization": f"Bearer {live_main_worker_schemas['token']}"}
+ schema_id = "list_exclude.v1"
+
+ requests.put(base + f"/schema/{schema_id}",
+ json={"type": "object"}, headers=headers, timeout=5)
+ listed_before = requests.get(base + "/admin/schemas", timeout=5).json()
+ assert schema_id in listed_before
+
+ requests.delete(base + f"/schema/{schema_id}",
+ headers=headers, timeout=5)
+
+ listed_after = requests.get(base + "/admin/schemas", timeout=5).json()
+ assert schema_id not in listed_after
+
+
+def test_update_schema_roundtrip(live_main_worker_schemas):
+ """PUT (update) then GET must return the updated schema content."""
+ base = live_main_worker_schemas["rest_url"]
+ headers = {"Authorization": f"Bearer {live_main_worker_schemas['token']}"}
+ schema_id = "update_test.v1"
+
+ original = {"type": "object", "properties": {"name": {"type": "string"}},
+ "required": ["name"]}
+ updated = {"type": "object",
+ "properties": {"name": {"type": "string"},
+ "age": {"type": "integer"}},
+ "required": ["name", "age"]}
+
+ requests.put(base + f"/schema/{schema_id}",
+ json=original, headers=headers, timeout=5)
+
+ put_r = requests.put(base + f"/schema/{schema_id}",
+ json=updated, headers=headers, timeout=5)
+ assert put_r.status_code == 200
+
+ get_r = requests.get(base + f"/schema/{schema_id}", timeout=5)
+ assert get_r.status_code == 200
+ data = get_r.json()
+ assert "age" in data.get("properties", {})
+ assert "age" in data.get("required", [])
+