From df6e314f8ab9ad83bca0ad158be920773fd144b6 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Tue, 20 Oct 2026 09:49:21 +1100 Subject: [PATCH 1/2] feat(history): unified change history API Co-Authored-By: Claude Opus 5.5 --- OPENAPI_DOC.yml | 485 +++++++++++++++++- shard.lock | 6 +- shard.yml | 2 +- spec/controllers/groups/history_spec.cr | 15 +- spec/controllers/history_spec.cr | 150 ++++++ spec/helper.cr | 4 +- .../controllers/application.cr | 31 ++ src/placeos-rest-api/controllers/groups.cr | 3 - .../controllers/groups/history.cr | 42 +- .../controllers/groups/invitation.cr | 2 - .../controllers/groups/user.cr | 3 - .../controllers/groups/zone.cr | 3 - src/placeos-rest-api/controllers/history.cr | 104 ++++ .../utilities/current-user.cr | 4 + 14 files changed, 793 insertions(+), 61 deletions(-) create mode 100644 spec/controllers/history_spec.cr create mode 100644 src/placeos-rest-api/controllers/history.cr diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml index 7a2ae2ff..c9138e9f 100644 --- a/OPENAPI_DOC.yml +++ b/OPENAPI_DOC.yml @@ -18610,16 +18610,18 @@ paths: /api/engine/v2/group_history: summary: Audit history of changes to groups and their memberships get: - summary: List audit entries. sys_admin sees everything; other callers must - description: 'List audit entries. sys_admin sees everything; other callers must + summary: List audit entries, newest first. sys_admin sees everything; other + description: 'List audit entries, newest first. sys_admin sees everything; other - pass `group_id=` and have Manage on it.' + callers must pass `group_id=` and have Manage on it.' tags: - History operationId: PlaceOS::Api::Groups::History_index parameters: - name: group_id in: query + description: only return changes to this group and its memberships + example: 0192d1c8-2f0e-7cc1-9d2a-6f1c7e1b3a51 schema: anyOf: - type: string @@ -18627,11 +18629,14 @@ paths: - type: "null" - name: limit in: query + description: the maximum number of results to return + example: 100 schema: type: integer format: Int32 - name: offset in: query + description: the starting offset of the result set, used to implement pagination schema: type: integer format: Int32 @@ -18667,7 +18672,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/PlaceOS.Model.GroupHistory' + $ref: '#/components/schemas/PlaceOS.Model.History' "409": description: Conflict content: @@ -18765,7 +18770,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.GroupHistory' + $ref: '#/components/schemas/PlaceOS.Model.History' "409": description: Conflict content: @@ -20686,6 +20691,428 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/history: + summary: Change history of the platform's models, see `PlaceOS::Model::History` + get: + summary: List changes, newest first + tags: + - History + operationId: PlaceOS::Api::History_index + parameters: + - name: resource_types + in: query + description: only return changes to these resource types (table names) + example: + - zone + - sys + schema: + anyOf: + - type: "null" + - type: array + items: + type: string + - name: exclude_resource_types + in: query + description: exclude changes to these resource types (table names) + example: + - bookings + - event_metadatas + schema: + anyOf: + - type: "null" + - type: array + items: + type: string + - name: resource_id + in: query + description: only return changes to this resource, usually combined with `resource_types` + example: zone-1234 + schema: + anyOf: + - type: "null" + - type: string + - name: correlation_id + in: query + description: only return changes related to this parent, such as a group, + playlist or booking + example: 0192d1c8-2f0e-7cc1-9d2a-6f1c7e1b3a51 + schema: + anyOf: + - type: "null" + - type: string + - name: user_id + in: query + description: only return changes made by this user + example: user-1234 + schema: + anyOf: + - type: "null" + - type: string + - name: actions + in: query + description: 'only return these kinds of change: create, update, delete, approve + or restore' + example: + - delete + schema: + anyOf: + - type: "null" + - type: array + items: + type: string + - name: period_start + in: query + description: only return changes made at or after this time, as a unix epoch + example: 1729382400 + schema: + anyOf: + - type: integer + format: Int64 + - type: "null" + - name: period_end + in: query + description: only return changes made before this time, as a unix epoch + example: 1729468800 + schema: + anyOf: + - type: integer + format: Int64 + - type: "null" + - name: limit + in: query + description: the maximum number of results to return + example: 100 + schema: + type: integer + format: Int32 + - name: offset + in: query + description: the starting offset of the result set, used to implement pagination + schema: + type: integer + format: Int32 + - name: q + in: query + description: 'filters results by the given text: a record matches when any + word matches its searchable fields as a whole word, and the last word also + matches as a prefix (search as you type)' + schema: + type: string + - name: ref + in: query + description: deprecated, ignored — pagination follows the `Link` header's + offset + schema: + anyOf: + - type: "null" + - type: string + - name: fields + in: query + description: deprecated, ignored — search covers the resource's indexed fields + style: form + explode: false + schema: + type: array + items: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlaceOS.Model.History' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/history/{id}: + summary: Change history of the platform's models, see `PlaceOS::Model::History` + get: + tags: + - History + operationId: PlaceOS::Api::History_show + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.History' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/history/undelete: + summary: Change history of the platform's models, see `PlaceOS::Model::History` + post: + summary: Restores a deleted record from its change history, keeping its id. + description: 'Restores a deleted record from its change history, keeping its + id. + + Records deleted alongside it are not restored and redacted fields, such + + as passwords and secrets, are left empty.' + tags: + - History + operationId: PlaceOS::Api::History_undelete + parameters: + - name: resource_type + in: query + description: the type (table name) of the deleted resource + example: zone + required: true + schema: + type: string + - name: resource_id + in: query + description: the id of the deleted resource + example: zone-1234 + required: true + schema: + type: string + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/PgORM.Base' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' /api/engine/v2/maps/ai/capabilities: summary: AI editing of a level's SVG map, with the signage AI provider rows. description: 'AI editing of a level''s SVG map, with the signage AI provider rows. @@ -46329,7 +46756,7 @@ components: within the current authority. Lightweight — returns the group rows plus the user''s effective permission bitmask per group.' - PlaceOS.Model.GroupHistory: + PlaceOS.Model.History: type: object properties: id: @@ -46337,16 +46764,15 @@ components: - type: string format: uuid - type: "null" - group_id: + resource_type: anyOf: - - type: string - format: uuid - type: "null" - user_id: + - type: string + resource_id: anyOf: - type: "null" - type: string - email: + correlation_id: anyOf: - type: "null" - type: string @@ -46354,20 +46780,32 @@ components: anyOf: - type: "null" - type: string - resource_type: + changed_fields: + anyOf: + - type: "null" + - type: array + items: + type: string + diff: + anyOf: + - type: object + - type: "null" + snapshot: + anyOf: + - type: object + - type: "null" + user_id: anyOf: - type: "null" - type: string - resource_id: + user_email: anyOf: - type: "null" - type: string - changed_fields: + context: anyOf: + - type: object - type: "null" - - type: array - items: - type: string created_at: anyOf: - type: string @@ -46489,6 +46927,8 @@ components: anyOf: - type: "null" - $ref: '#/components/schemas/PlaceOS.Model.Zone' + PgORM.Base: + type: object PlaceOS.Api.MapAI.Capabilities: type: object properties: @@ -49205,10 +49645,6 @@ components: anyOf: - type: "null" - type: string - admin_destroy_lock: - anyOf: - - type: "null" - - $ref: '#/components/schemas/PgORM.PgAdvisoryLock' id: anyOf: - type: "null" @@ -49910,13 +50346,6 @@ components: required: - day_of_week - blocks - PgORM.PgAdvisoryLock: - type: object - properties: - key: - type: string - required: - - key PlaceOS.Model.Trigger.Conditions.Comparison: type: object properties: diff --git a/shard.lock b/shard.lock index d9072e23..efbcdba2 100644 --- a/shard.lock +++ b/shard.lock @@ -11,7 +11,7 @@ shards: active-model: git: https://github.com/spider-gazelle/active-model.git - version: 4.5.0 + version: 4.7.0 ameba: git: https://github.com/crystal-ameba/ameba.git @@ -187,7 +187,7 @@ shards: pg-orm: git: https://github.com/spider-gazelle/pg-orm.git - version: 2.4.1 + version: 2.5.1 pinger: git: https://github.com/spider-gazelle/pinger.git @@ -223,7 +223,7 @@ shards: placeos-models: git: https://github.com/placeos/models.git - version: 9.120.0 + version: 9.122.0 placeos-resource: git: https://github.com/place-labs/resource.git diff --git a/shard.yml b/shard.yml index e309c2e8..d87454b7 100644 --- a/shard.yml +++ b/shard.yml @@ -58,7 +58,7 @@ dependencies: # Database view placeos-models: github: placeos/models - version: ">= 9.120" + version: ">= 9.122" # An abstraction over Fibers promise: diff --git a/spec/controllers/groups/history_spec.cr b/spec/controllers/groups/history_spec.cr index 82f913ae..a669a576 100644 --- a/spec/controllers/groups/history_spec.cr +++ b/spec/controllers/groups/history_spec.cr @@ -10,7 +10,7 @@ module PlaceOS::Api manager, manager_headers = Spec::Authentication.authentication(sys_admin: false, support: false) group = Model::Generator.group(authority: authority) - group.acting_user = admin + group.history_metadata = Model::History::Metadata.new.with_user(admin) group.save! Model::Generator.group_user(user: manager, group: group, permissions: Model::Permissions::Manage).save! @@ -21,6 +21,19 @@ module PlaceOS::Api # non-admin with group_id they manage → 200 scoped = client.get("#{base}?group_id=#{group.id}", headers: manager_headers) scoped.status_code.should eq 200 + entries = Array(Model::History).from_json(scoped.body) + entries.map(&.resource_type).should eq ["group_users", "groups"] + entries.all?(&.correlation_id.==(group.id.to_s)).should be_true + entries.last.user_id.should eq admin.id + + # entries of the group can be shown by its manager + shown = client.get("#{base}#{entries.last.id}", headers: manager_headers) + shown.status_code.should eq 200 + + # entries of other resources are not group history + other = Model::Generator.zone.save! + zone_entry = other.change_history.to_a.first + client.get("#{base}#{zone_entry.id}", headers: Spec::Authentication.headers).status_code.should eq 404 # sys_admin unfiltered → 200 client.get(base, headers: Spec::Authentication.headers).status_code.should eq 200 diff --git a/spec/controllers/history_spec.cr b/spec/controllers/history_spec.cr new file mode 100644 index 00000000..2a2df428 --- /dev/null +++ b/spec/controllers/history_spec.cr @@ -0,0 +1,150 @@ +require "../helper" + +module PlaceOS::Api + describe History do + base = History.base_route + + before_each { Model::History.clear } + + describe "metadata" do + it "attributes changes to the user and client of the request" do + user, headers = Spec::Authentication.authentication + headers["X-App-Id"] = "app-1" + headers["X-App-Name"] = "backoffice" + headers["X-App-Date"] = "2026-10-20" + headers["X-App-Build"] = "abc123" + headers["User-Agent"] = "Spec Browser" + headers["X-Forwarded-For"] = "10.0.0.1" + + result = client.post(Zones.base_route, body: Model::Generator.zone.to_json, headers: headers) + result.status_code.should eq 201 + zone = Model::Zone.from_trusted_json(result.body) + + entry = zone.change_history.to_a.first + entry.action.should eq "create" + entry.user_id.should eq user.id + entry.user_email.should eq user.email.to_s + + metadata = entry.metadata + metadata.user_name.should eq user.name + metadata.app_id.should eq "app-1" + metadata.app_name.should eq "backoffice" + metadata.app_date.should eq "2026-10-20" + metadata.app_build.should eq "abc123" + metadata.user_agent.should eq "Spec Browser" + metadata.ip.should eq "10.0.0.1" + metadata.request_id.should eq result.headers["X-Request-ID"] + metadata.api_key_id.should be_nil + + Model::History.current_metadata.should be_nil + end + + it "records the API key used" do + user, headers = Spec::Authentication.x_api_authentication + api_key = Model::ApiKey.find_key!(headers["X-API-Key"]) + api_key.permissions = Model::UserJWT::Permissions::Admin + api_key.save! + + result = client.post(Zones.base_route, body: Model::Generator.zone.to_json, headers: headers) + result.status_code.should eq 201 + zone = Model::Zone.from_trusted_json(result.body) + + entry = zone.change_history.to_a.first + entry.user_id.should eq user.id + entry.metadata.api_key_id.should eq api_key.id + entry.metadata.api_key_name.should eq api_key.name + end + end + + describe "index" do + it "requires an admin" do + result = client.get(base, headers: Spec::Authentication.headers(sys_admin: false, support: true)) + result.status_code.should eq 403 + end + + it "filters changes, newest first" do + zone = Model::Generator.zone.save! + zone.name = "renamed" + zone.save! + system = Model::Generator.control_system.save! + old = Model::Generator.history(resource_id: "zone-old", created_at: 2.days.ago).save! + Model::Generator.history(resource_id: "zone-group", correlation_id: "group-1").save! + + query = ->(params : Hash(String, String)) do + result = client.get("#{base}?#{HTTP::Params.encode(params)}", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + Array(Model::History).from_json(result.body) + end + + entries = query.call({"resource_types" => "zone", "resource_id" => zone.id.as(String)}) + entries.map(&.action).should eq ["update", "create"] + + query.call({"resource_types" => "sys"}).map(&.resource_id).should eq [system.id] + query.call({"resource_types" => "zone,sys", "exclude_resource_types" => "zone"}).map(&.resource_id).should eq [system.id] + query.call({"correlation_id" => "group-1"}).map(&.resource_id).should eq ["zone-group"] + query.call({"resource_id" => zone.id.as(String), "actions" => "update"}).map(&.resource_id).should eq [zone.id] + query.call({"period_end" => 1.day.ago.to_unix.to_s}).map(&.id).should eq [old.id] + query.call({"period_start" => 1.day.ago.to_unix.to_s}).map(&.id).should_not contain(old.id) + end + + it "filters by user and paginates" do + user, headers = Spec::Authentication.authentication + 3.times do + client.post(Zones.base_route, body: Model::Generator.zone.to_json, headers: headers).status_code.should eq 201 + end + Model::Generator.zone.save! + + result = client.get("#{base}?user_id=#{user.id}&limit=2", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + result.headers["X-Total-Count"].should eq "3" + result.headers["Link"].should contain("offset=2") + Array(Model::History).from_json(result.body).map(&.user_id).should eq [user.id, user.id] + end + end + + describe "show" do + it "returns an entry" do + entry = Model::Generator.history.save! + result = client.get("#{base}#{entry.id}", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + Model::History.from_trusted_json(result.body).id.should eq entry.id + end + end + + describe "undelete" do + it "restores a deleted record" do + headers = Spec::Authentication.headers + zone = Model::Generator.zone + zone.description = "restore me" + zone.save! + client.delete("#{Zones.base_route}#{zone.id}", headers: headers).status_code.should eq 202 + Model::Zone.find?(zone.id.as(String)).should be_nil + + params = HTTP::Params.encode({"resource_type" => "zone", "resource_id" => zone.id.as(String)}) + result = client.post("#{base}undelete?#{params}", headers: headers) + result.status_code.should eq 201 + Model::Zone.from_trusted_json(result.body).id.should eq zone.id + Model::Zone.find!(zone.id.as(String)).description.should eq "restore me" + zone.change_history.to_a.map(&.action).should eq ["restore", "delete", "create"] + + # already restored + client.post("#{base}undelete?#{params}", headers: headers).status_code.should eq 409 + end + + it "returns 404 when there is nothing to restore" do + headers = Spec::Authentication.headers + params = HTTP::Params.encode({"resource_type" => "zone", "resource_id" => "zone-missing"}) + client.post("#{base}undelete?#{params}", headers: headers).status_code.should eq 404 + + params = HTTP::Params.encode({"resource_type" => "unknown", "resource_id" => "zone-missing"}) + client.post("#{base}undelete?#{params}", headers: headers).status_code.should eq 404 + end + + it "requires an admin" do + params = HTTP::Params.encode({"resource_type" => "zone", "resource_id" => "zone-missing"}) + headers = Spec::Authentication.headers(sys_admin: false, support: true) + client.post("#{base}undelete?#{params}", headers: headers).status_code.should eq 403 + end + end + end +end diff --git a/spec/helper.cr b/spec/helper.cr index a9539305..2f4b83f8 100644 --- a/spec/helper.cr +++ b/spec/helper.cr @@ -61,7 +61,7 @@ def clear_tables # and must be dropped before their parents. Done sequentially since # some of them reference each other. [ - PlaceOS::Model::GroupHistory, + PlaceOS::Model::History, PlaceOS::Model::GroupInvitation, PlaceOS::Model::GroupZone, PlaceOS::Model::GroupUser, @@ -106,7 +106,7 @@ end # that creates a root group fails with a validation error. def clear_group_tables [ - PlaceOS::Model::GroupHistory, + PlaceOS::Model::History, PlaceOS::Model::GroupInvitation, PlaceOS::Model::GroupZone, PlaceOS::Model::GroupUser, diff --git a/src/placeos-rest-api/controllers/application.cr b/src/placeos-rest-api/controllers/application.cr index 7de9ad01..cfaa8849 100644 --- a/src/placeos-rest-api/controllers/application.cr +++ b/src/placeos-rest-api/controllers/application.cr @@ -193,6 +193,37 @@ module PlaceOS::Api response.headers["X-Request-ID"] = request_id end + # Attributes model changes made during the request in their change + # history. The metadata is only built when a change is recorded, after + # the request has been authenticated. + @[AC::Route::Filter(:around_action)] + def with_history_metadata(&) + provider = ::PlaceOS::Model::History::MetadataProvider.new { history_metadata(request, @current_user) } + ::PlaceOS::Model::History.with_metadata(provider) { yield } + end + + # Who made a change and from where, see `PlaceOS::Model::History::Metadata` + def history_metadata(request : HTTP::Request, current_user : ::PlaceOS::Model::User?) : ::PlaceOS::Model::History::Metadata + headers = request.headers + metadata = ::PlaceOS::Model::History::Metadata.new( + api_key_id: api_key.try(&.id), + api_key_name: api_key.try(&.name), + app_id: history_header(headers, "X-App-Id"), + app_name: history_header(headers, "X-App-Name"), + app_date: history_header(headers, "X-App-Date"), + app_build: history_header(headers, "X-App-Build"), + user_agent: history_header(headers, "User-Agent"), + ip: client_ip, + request_id: request_id, + ) + current_user ? metadata.with_user(current_user) : metadata + end + + # Client supplied header, limited in length as it is stored with each change + private def history_header(headers : HTTP::Headers, name : String) : String? + headers[name]?.presence.try(&.[0, 256]) + end + ########################################################################### # Error Handlers ########################################################################### diff --git a/src/placeos-rest-api/controllers/groups.cr b/src/placeos-rest-api/controllers/groups.cr index 31853f21..b9354304 100644 --- a/src/placeos-rest-api/controllers/groups.cr +++ b/src/placeos-rest-api/controllers/groups.cr @@ -191,7 +191,6 @@ module PlaceOS::Api update = group_update current = current_group current.assign_attributes(update) - current.acting_user = current_user raise Error::ModelValidation.new(current.errors) unless current.save current end @@ -203,7 +202,6 @@ module PlaceOS::Api group = group_update # Force the authority to the caller's — no cross-authority moves. group.authority_id = current_authority.as(::PlaceOS::Model::Authority).id.as(String) - group.acting_user = current_user raise Error::ModelValidation.new(group.errors) unless group.save group end @@ -212,7 +210,6 @@ module PlaceOS::Api @[AC::Route::DELETE("/:id", status_code: HTTP::Status::ACCEPTED)] def destroy : Nil group = current_group - group.acting_user = current_user group.destroy end diff --git a/src/placeos-rest-api/controllers/groups/history.cr b/src/placeos-rest-api/controllers/groups/history.cr index 6d030ef7..24e5ba24 100644 --- a/src/placeos-rest-api/controllers/groups/history.cr +++ b/src/placeos-rest-api/controllers/groups/history.cr @@ -1,5 +1,5 @@ require "placeos-models/group" -require "placeos-models/group/history" +require "placeos-models/history" require "../application" @@ -10,6 +10,9 @@ module PlaceOS::Api base "/api/engine/v2/group_history/" + # Change history of these tables is correlated to the group + RESOURCE_TYPES = %w[groups group_users group_zones group_invitations] + # Scopes ############################################################################################### @@ -20,49 +23,58 @@ module PlaceOS::Api @[AC::Route::Filter(:before_action, only: [:show])] def find_current_history(id : UUID) Log.context.set(group_history_id: id.to_s) - @current_history = ::PlaceOS::Model::GroupHistory.find!(id) + history = ::PlaceOS::Model::History.find!(id) + raise Error::NotFound.new("not a group history entry") unless history.resource_type.in?(RESOURCE_TYPES) + @current_history = history end - getter! current_history : ::PlaceOS::Model::GroupHistory + getter! current_history : ::PlaceOS::Model::History @[AC::Route::Filter(:before_action, only: [:show])] def check_show_permissions return if user_admin? - if (gid = current_history.group_id) - group = ::PlaceOS::Model::Group.find!(gid) + if gid = current_history.correlation_id + group = ::PlaceOS::Model::Group.find!(UUID.new(gid)) ensure_manage!(current_user, group) else - # History entry without a group_id — sys_admin only. + # History entry without a group — sys_admin only. raise Error::Forbidden.new end end ############################################################################################### - # List audit entries. sys_admin sees everything; other callers must - # pass `group_id=` and have Manage on it. + # List audit entries, newest first. sys_admin sees everything; other + # callers must pass `group_id=` and have Manage on it. @[AC::Route::GET("/")] def index( + @[AC::Param::Info(description: "only return changes to this group and its memberships", example: "0192d1c8-2f0e-7cc1-9d2a-6f1c7e1b3a51")] group_id : UUID? = nil, + @[AC::Param::Info(description: "the maximum number of results to return", example: "100")] limit : Int32 = 100, + @[AC::Param::Info(description: "the starting offset of the result set, used to implement pagination")] offset : Int32 = 0, - ) : Array(::PlaceOS::Model::GroupHistory) - if user_admin? - query = ::PlaceOS::Model::GroupHistory.all - query = query.where(group_id: group_id) if group_id - else + ) : Array(::PlaceOS::Model::History) + unless user_admin? # Non-admin must scope to a specific group they manage. raise Error::Forbidden.new("group_id is required") unless group_id group = ::PlaceOS::Model::Group.find!(group_id) ensure_manage!(current_user, group) - query = ::PlaceOS::Model::GroupHistory.where(group_id: group_id) end + query = if group_id + ::PlaceOS::Model::History.correlated(group_id.to_s) + else + ::PlaceOS::Model::History + .where(resource_type: RESOURCE_TYPES) + .order(created_at: :desc, id: :desc) + end + paginate_sql(query, type: "group_history", limit: limit, offset: offset) end @[AC::Route::GET("/:id")] - def show : ::PlaceOS::Model::GroupHistory + def show : ::PlaceOS::Model::History current_history end end diff --git a/src/placeos-rest-api/controllers/groups/invitation.cr b/src/placeos-rest-api/controllers/groups/invitation.cr index 10482188..bb970bb7 100644 --- a/src/placeos-rest-api/controllers/groups/invitation.cr +++ b/src/placeos-rest-api/controllers/groups/invitation.cr @@ -123,7 +123,6 @@ module PlaceOS::Api permissions: perms, expires_at: payload.expires_at, ) - invitation.acting_user = current_user raise Error::ModelValidation.new(invitation.errors) unless invitation.save InvitationCreatedResponse.new(invitation, invitation.plaintext_secret.as(String)) end @@ -131,7 +130,6 @@ module PlaceOS::Api @[AC::Route::DELETE("/:id", status_code: HTTP::Status::ACCEPTED)] def destroy : Nil invitation = current_invitation - invitation.acting_user = current_user invitation.destroy end diff --git a/src/placeos-rest-api/controllers/groups/user.cr b/src/placeos-rest-api/controllers/groups/user.cr index 06aad6a2..da47613b 100644 --- a/src/placeos-rest-api/controllers/groups/user.cr +++ b/src/placeos-rest-api/controllers/groups/user.cr @@ -161,7 +161,6 @@ module PlaceOS::Api @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] def create : ::PlaceOS::Model::GroupUser gu = group_user_update - gu.acting_user = current_user raise Error::ModelValidation.new(gu.errors) unless gu.save gu end @@ -173,7 +172,6 @@ module PlaceOS::Api # Only permissions are mutable on this junction; composite PK is immutable. new_perms = group_user_update.permissions current.permissions = new_perms - current.acting_user = current_user raise Error::ModelValidation.new(current.errors) unless current.save current end @@ -181,7 +179,6 @@ module PlaceOS::Api @[AC::Route::DELETE("/:user_id/:group_id", status_code: HTTP::Status::ACCEPTED)] def destroy : Nil gu = current_group_user - gu.acting_user = current_user gu.destroy end end diff --git a/src/placeos-rest-api/controllers/groups/zone.cr b/src/placeos-rest-api/controllers/groups/zone.cr index c8acac71..09e5046c 100644 --- a/src/placeos-rest-api/controllers/groups/zone.cr +++ b/src/placeos-rest-api/controllers/groups/zone.cr @@ -140,7 +140,6 @@ module PlaceOS::Api @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] def create : ::PlaceOS::Model::GroupZone gz = group_zone_update - gz.acting_user = current_user raise Error::ModelValidation.new(gz.errors) unless gz.save gz end @@ -152,7 +151,6 @@ module PlaceOS::Api update = group_zone_update current.permissions = update.permissions current.deny = update.deny - current.acting_user = current_user raise Error::ModelValidation.new(current.errors) unless current.save current end @@ -160,7 +158,6 @@ module PlaceOS::Api @[AC::Route::DELETE("/:group_id/:zone_id", status_code: HTTP::Status::ACCEPTED)] def destroy : Nil gz = current_group_zone - gz.acting_user = current_user gz.destroy end end diff --git a/src/placeos-rest-api/controllers/history.cr b/src/placeos-rest-api/controllers/history.cr new file mode 100644 index 00000000..ed1609ef --- /dev/null +++ b/src/placeos-rest-api/controllers/history.cr @@ -0,0 +1,104 @@ +require "placeos-models/history" + +require "./application" + +module PlaceOS::Api + # Change history of the platform's models, see `PlaceOS::Model::History` + class History < Application + base "/api/engine/v2/history/" + + # Scopes + ############################################################################################### + + before_action :can_read, only: [:index, :show] + before_action :can_write, only: [:undelete] + + before_action :check_admin + + ############################################################################################### + + # Restores the latest deleted version of a record, nil if `resource_type` + # has no change history + def self.undelete(resource_type : String, resource_id : String) : ::PgORM::Base? + {% begin %} + case resource_type + {% for model in ::PlaceOS::Model::History::Tracked.includers %} + when {{ model }}.table_name then {{ model }}.undelete(resource_id) + {% end %} + end + {% end %} + end + + @[AC::Route::Filter(:before_action, only: [:show])] + def find_current_history(id : UUID) + Log.context.set(history_id: id.to_s) + @current_history = ::PlaceOS::Model::History.find!(id) + end + + getter! current_history : ::PlaceOS::Model::History + + ############################################################################################### + + # List changes, newest first + @[AC::Route::GET("/", converters: {resource_types: ConvertStringArray, exclude_resource_types: ConvertStringArray, actions: ConvertStringArray})] + def index( + @[AC::Param::Info(description: "only return changes to these resource types (table names)", example: "zone,sys")] + resource_types : Array(String)? = nil, + @[AC::Param::Info(description: "exclude changes to these resource types (table names)", example: "bookings,event_metadatas")] + exclude_resource_types : Array(String)? = nil, + @[AC::Param::Info(description: "only return changes to this resource, usually combined with `resource_types`", example: "zone-1234")] + resource_id : String? = nil, + @[AC::Param::Info(description: "only return changes related to this parent, such as a group, playlist or booking", example: "0192d1c8-2f0e-7cc1-9d2a-6f1c7e1b3a51")] + correlation_id : String? = nil, + @[AC::Param::Info(description: "only return changes made by this user", example: "user-1234")] + user_id : String? = nil, + @[AC::Param::Info(description: "only return these kinds of change: create, update, delete, approve or restore", example: "delete")] + actions : Array(String)? = nil, + @[AC::Param::Info(description: "only return changes made at or after this time, as a unix epoch", example: "1729382400")] + period_start : Int64? = nil, + @[AC::Param::Info(description: "only return changes made before this time, as a unix epoch", example: "1729468800")] + period_end : Int64? = nil, + @[AC::Param::Info(description: "the maximum number of results to return", example: "100")] + limit : Int32 = 100, + @[AC::Param::Info(description: "the starting offset of the result set, used to implement pagination")] + offset : Int32 = 0, + ) : Array(::PlaceOS::Model::History) + query = ::PlaceOS::Model::History.order(created_at: :desc, id: :desc) + query = query.where(resource_type: resource_types) if resource_types && !resource_types.empty? + query = query.where_not(resource_type: exclude_resource_types) if exclude_resource_types && !exclude_resource_types.empty? + query = query.where(resource_id: resource_id) if resource_id + query = query.where(correlation_id: correlation_id) if correlation_id + query = query.where(user_id: user_id) if user_id + query = query.where(action: actions) if actions && !actions.empty? + query = query.where("created_at >= ?", Time.unix(period_start)) if period_start + query = query.where("created_at < ?", Time.unix(period_end)) if period_end + + paginate_sql(query, type: "history", limit: limit.clamp(0, 10_000), offset: offset.clamp(0, 1_000_000)) + end + + @[AC::Route::GET("/:id")] + def show : ::PlaceOS::Model::History + current_history + end + + # Restores a deleted record from its change history, keeping its id. + # Records deleted alongside it are not restored and redacted fields, such + # as passwords and secrets, are left empty. + @[AC::Route::POST("/undelete", status_code: HTTP::Status::CREATED)] + def undelete( + @[AC::Param::Info(description: "the type (table name) of the deleted resource", example: "zone")] + resource_type : String, + @[AC::Param::Info(description: "the id of the deleted resource", example: "zone-1234")] + resource_id : String, + ) : ::PgORM::Base + begin + self.class.undelete(resource_type, resource_id) || raise Error::NotFound.new("#{resource_type} has no change history") + rescue error : ::PgORM::Error::RecordNotFound + raise error + rescue error : ::PgORM::Error + # i.e. the record already exists or now clashes with another + raise Error::Conflict.new("#{resource_type} #{resource_id} could not be restored: #{error.message}") + end + end + end +end diff --git a/src/placeos-rest-api/utilities/current-user.cr b/src/placeos-rest-api/utilities/current-user.cr index 11bed6e1..7b9adf80 100644 --- a/src/placeos-rest-api/utilities/current-user.cr +++ b/src/placeos-rest-api/utilities/current-user.cr @@ -11,6 +11,9 @@ module PlaceOS::Api # Helper to grab user and authority from a request module Utils::CurrentUser + # The API key used to authenticate the request, if any + getter api_key : ::PlaceOS::Model::ApiKey? = nil + # Parses, and validates JWT if present. # Throws Error::MissingBearer and JWT::Error. @@ -28,6 +31,7 @@ module PlaceOS::Api Log.context.set(api_key_id: api_key.id, api_key_name: api_key.name) ensure_matching_domain(user_token) @user_token = user_token + @api_key = api_key @current_user = ::PlaceOS::Model::User.find(user_token.id) return user_token rescue e : Error::Unauthorized From 34492188015b58682b24af83ae5c0607441c1727 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Tue, 20 Oct 2026 10:12:49 +1100 Subject: [PATCH 2/2] fix(spec): MCP is stateless since action-controller 9.2 Co-Authored-By: Claude Opus 5.5 --- spec/controllers/chat_gpt_plugin_spec.cr | 3 +-- spec/mcp_spec.cr | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/spec/controllers/chat_gpt_plugin_spec.cr b/spec/controllers/chat_gpt_plugin_spec.cr index b2359558..6e557353 100644 --- a/spec/controllers/chat_gpt_plugin_spec.cr +++ b/spec/controllers/chat_gpt_plugin_spec.cr @@ -19,7 +19,6 @@ module PlaceOS::Api headers["Host"] = "localhost" response = rpc.call(mcp_path.call(sys_id), headers, "initialize", {"protocolVersion" => JSON::Any.new("2025-11-25")}) response.status_code.should eq 200 - headers["Mcp-Session-Id"] = response.headers["Mcp-Session-Id"] {headers, JSON.parse(response.body)["result"]} } call_tool = ->(sys_id : String, headers : HTTP::Headers, name : String, arguments : Hash(String, JSON::Any)) { @@ -120,7 +119,7 @@ module PlaceOS::Api headers["Accept"] = "application/json" headers["Host"] = "localhost" response = mcp_client.post(MCP::PATH, headers: headers, body: {jsonrpc: "2.0", id: 1, method: "initialize", params: {protocolVersion: "2025-11-25"}}.to_json) - headers["Mcp-Session-Id"] = response.headers["Mcp-Session-Id"] + response.status_code.should eq 200 response = mcp_client.post(MCP::PATH, headers: headers, body: {jsonrpc: "2.0", id: 2, method: "tools/call", params: {name: "list_toolboxes", arguments: {} of String => String}}.to_json) names = JSON.parse(response.body)["result"]["structuredContent"]["toolboxes"].as_a.map(&.["name"].as_s) names.should_not contain "chat_gpt_plugin" diff --git a/spec/mcp_spec.cr b/spec/mcp_spec.cr index 71d11ecd..92f8bb32 100644 --- a/spec/mcp_spec.cr +++ b/spec/mcp_spec.cr @@ -19,12 +19,11 @@ module PlaceOS::Api initialize_params = {"protocolVersion" => JSON::Any.new("2025-11-25")} no_params = {} of String => JSON::Any - # establishes a session, returning the headers to use for it + # initializes the stateless server, returning the headers to use for later requests open_session = ->(credentials : HTTP::Headers) { headers = mcp_headers.call(credentials) response = rpc.call(headers, "initialize", initialize_params) response.status_code.should eq 200 - headers["Mcp-Session-Id"] = response.headers["Mcp-Session-Id"] headers } call_tool = ->(headers : HTTP::Headers, name : String, arguments : Hash(String, JSON::Any)) {