Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
485 changes: 457 additions & 28 deletions OPENAPI_DOC.yml

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions shard.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion shard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ dependencies:
# Database view
placeos-models:
github: placeos/models
version: ">= 9.120"
version: ">= 9.122"

# An abstraction over Fibers
promise:
Expand Down
3 changes: 1 addition & 2 deletions spec/controllers/chat_gpt_plugin_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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"
Expand Down
15 changes: 14 additions & 1 deletion spec/controllers/groups/history_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -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
Expand Down
150 changes: 150 additions & 0 deletions spec/controllers/history_spec.cr
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions spec/helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions spec/mcp_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
31 changes: 31 additions & 0 deletions src/placeos-rest-api/controllers/application.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
###########################################################################
Expand Down
3 changes: 0 additions & 3 deletions src/placeos-rest-api/controllers/groups.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
Loading
Loading