From 5846b61260aee92b2eec8ca87281d6807f485844 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:55:09 +0000 Subject: [PATCH 1/2] Initial plan From 153cba0f8cf6c8837b1f6dbcdb5a8e89c2185053 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:09:45 +0000 Subject: [PATCH 2/2] feat: add DELETE/update schema API, gRPC ops, frontend, tests, and docs Co-authored-by: DeltaRule <83079740+DeltaRule@users.noreply.github.com> --- README.md | 50 +++++++ api/openapi.yaml | 50 +++++++ api/proto/worker.proto | 4 +- cmd/main-worker/server.go | 77 ++++++++++- cmd/main-worker/server_test.go | 192 +++++++++++++++++++++++++++ cmd/main-worker/static/app.html | 41 +++++- cmd/main-worker/static/css/delta.css | 9 ++ docs/usage/api-reference.md | 99 ++++++++++++++ docs/usage/schemas.md | 83 +++++++++++- pkg/schema/validator.go | 31 ++++- pkg/schema/validator_test.go | 60 +++++++++ tests/test_schema_api.py | 101 ++++++++++++++ 12 files changed, 787 insertions(+), 10 deletions(-) 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 @@