local urlmod = require("assay.url")
local CRM = "/api/admin/crm"
local WORK = "/api/work"
local function check_header_table(t, source)
for k in pairs(t) do
if type(k) ~= "string" then
error("neutron: " .. source .. " must map header names to values")
end
end
return t
end
local function resolve_extra_headers(from_opts)
if from_opts ~= nil then
if type(from_opts) ~= "table" then
error("neutron: opts.headers must be a table of header -> value")
end
return check_header_table(from_opts, "opts.headers")
end
local raw = env.get("NEUTRON_EXTRA_HEADERS")
if raw == nil or raw == "" then return nil end
local ok, parsed = pcall(json.parse, raw)
if not ok or type(parsed) ~= "table" then
error("neutron: NEUTRON_EXTRA_HEADERS is not a JSON object of header -> value")
end
return check_header_table(parsed, "NEUTRON_EXTRA_HEADERS")
end
local M = {}
function M.client(url, opts)
opts = opts or {}
local base_url = (url or env.get("NEUTRON_URL") or ""):gsub("/+$", "")
if base_url == "" then
error("neutron: no url — pass one or set NEUTRON_URL")
end
local token = opts.token or env.get("NEUTRON_TOKEN")
if not token then
error("neutron: no token — pass opts.token or set NEUTRON_TOKEN")
end
local cf_id = opts.cf_client_id or env.get("CF_ACCESS_CLIENT_ID")
local cf_secret = opts.cf_client_secret or env.get("CF_ACCESS_CLIENT_SECRET")
local extra = resolve_extra_headers(opts.headers)
local function headers()
local h = {
Authorization = "Bearer " .. token,
["Content-Type"] = "application/json",
}
if cf_id and cf_secret then
h["CF-Access-Client-Id"] = cf_id
h["CF-Access-Client-Secret"] = cf_secret
end
if extra then
for k, v in pairs(extra) do
if k:lower() ~= "authorization" then h[k] = tostring(v) end
end
end
return h
end
local function send(method, path_str, payload)
local url_full = base_url .. path_str
local h = headers()
if method == "GET" then return http.get(url_full, { headers = h }) end
if method == "POST" then return http.post(url_full, payload or {}, { headers = h }) end
if method == "PUT" then return http.put(url_full, payload or {}, { headers = h }) end
if method == "PATCH" then return http.patch(url_full, payload or {}, { headers = h }) end
if method == "DELETE" then return http.delete(url_full, { headers = h }) end
error("neutron: unsupported method " .. tostring(method))
end
local function seg(v) return urlmod.encode(tostring(v)) end
local function qs(path_str, query)
if not query then return path_str end
local parts = {}
for k, v in pairs(query) do
if v ~= nil then
parts[#parts + 1] = urlmod.encode(tostring(k)) .. "=" .. urlmod.encode(tostring(v))
end
end
table.sort(parts)
return #parts > 0 and (path_str .. "?" .. table.concat(parts, "&")) or path_str
end
local function rq(method, path_str, payload)
local resp = send(method, path_str, payload)
if resp.body == nil or resp.body == "" then return nil, resp.status end
local ok, parsed = pcall(json.parse, resp.body)
if ok then return parsed, resp.status end
return resp.body, resp.status
end
local function request(method, path_str, payload)
local resp = send(method, path_str, payload)
if resp.status >= 400 then
error("neutron: " .. method .. " " .. path_str .. " HTTP " .. resp.status .. ": " .. resp.body)
end
if resp.body == nil or resp.body == "" then return true end
return json.parse(resp.body)
end
local c = {}
function c:request(method, path_str, payload) return rq(method, path_str, payload) end
c.agents = {}
function c.agents:list()
return request("GET", "/api/admin/agents")
end
function c.agents:create(display_name, config)
local body = config or {}
body.display_name = display_name
return request("POST", "/api/admin/agents", body)
end
function c.agents:update(id, config)
return request("PUT", "/api/admin/agents/" .. id, config)
end
function c.agents:delete(id)
return request("DELETE", "/api/admin/agents/" .. id)
end
function c.agents:core()
return request("GET", "/api/admin/settings/agent")
end
function c.agents:update_core(config)
return request("PUT", "/api/admin/settings/agent", config)
end
c.secrets = {}
function c.secrets:list()
return request("GET", "/api/admin/secrets").secrets
end
function c.secrets:set(name, opts_)
return request("PUT", "/api/admin/secrets/" .. name, opts_ or {}).secrets
end
function c.secrets:value(name)
return request("GET", "/api/admin/secrets/" .. name .. "/value").value
end
function c.secrets:delete(name)
return request("DELETE", "/api/admin/secrets/" .. name).secrets
end
c.connections = {}
function c.connections:list()
return request("GET", "/api/admin/connections").connections
end
function c.connections:set(name, opts_)
return request("PUT", "/api/admin/connections/" .. name, opts_).connections
end
function c.connections:delete(name)
return request("DELETE", "/api/admin/connections/" .. name).connections
end
c.resources = {}
function c.resources:list()
return request("GET", "/api/admin/resources").resources
end
function c.resources:create(body)
return request("POST", "/api/admin/resources", body)
end
function c.resources:update(id, body)
return request("PUT", "/api/admin/resources/" .. id, body)
end
function c.resources:delete(id)
return request("DELETE", "/api/admin/resources/" .. id)
end
c.roles = {}
function c.roles:list()
return request("GET", "/api/admin/roles").roles
end
function c.roles:create(name)
return request("POST", "/api/admin/roles", { name = name })
end
function c.roles:member(id, email, action)
return request("PUT", "/api/admin/roles/" .. id .. "/members", { email = email, action = action })
end
function c.roles:delete(id)
return request("DELETE", "/api/admin/roles/" .. id)
end
c.tokens = {}
function c.tokens:list()
return request("GET", "/api/admin/tokens").tokens
end
function c.tokens:mint(name)
return request("POST", "/api/admin/tokens", { name = name })
end
function c.tokens:revoke(name)
return request("DELETE", "/api/admin/tokens/" .. name).tokens
end
c.settings = {}
function c.settings:get()
return request("GET", "/api/settings")
end
function c.settings:update(body)
return request("PUT", "/api/admin/settings", body)
end
c.channels = {}
function c.channels:get()
return request("GET", "/api/admin/settings/channels").channels
end
function c.channels:update(body)
return request("PUT", "/api/admin/settings/channels", body).channels
end
c.users = {}
function c.users:list()
return request("GET", "/api/admin/users").users
end
c.usage = {}
function c.usage:get(opts_)
local q = {}
if opts_ and opts_.from then q[#q + 1] = "from=" .. opts_.from end
if opts_ and opts_.to then q[#q + 1] = "to=" .. opts_.to end
local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or ""
return request("GET", "/api/admin/usage" .. suffix)
end
function c.assay_catalog()
return request("GET", "/api/admin/assay/catalog").modules
end
local CAMP = CRM .. "/campaigns/"
local PEOPLE = CRM .. "/people"
local INBOX = CRM .. "/inbox"
local INTEG = CRM .. "/integrations/"
c.crm = {
campaigns = {},
experiments = {},
people = {},
inbox = {},
sends = {},
replies = {},
suppressions = {},
fleet = {},
domains = {},
integrations = {},
budgets = {},
costs = {},
deals = {},
claims = {},
identities = {},
proposals = {},
reports = {},
}
local cg = c.crm.campaigns
function cg:list() return rq("GET", CRM .. "/campaigns") end
function cg:get(id) return rq("GET", CAMP .. seg(id)) end
function cg:overview(id) return rq("GET", CAMP .. seg(id) .. "/overview") end
function cg:pipeline(id) return rq("GET", CAMP .. seg(id) .. "/pipeline") end
function cg:board(id) return rq("GET", CAMP .. seg(id) .. "/board") end
function cg:companies(id) return rq("GET", CAMP .. seg(id) .. "/companies") end
function cg:setup(id) return rq("GET", CAMP .. seg(id) .. "/setup") end
function cg:create(body) return rq("POST", CRM .. "/campaigns", body) end
function cg:update(id, body) return rq("PATCH", CAMP .. seg(id), body) end
function cg:delete(id) return rq("DELETE", CAMP .. seg(id)) end
function cg:activate(id) return rq("POST", CAMP .. seg(id) .. "/activate") end
function cg:pause(id) return rq("POST", CAMP .. seg(id) .. "/pause") end
function cg:archive(id) return rq("POST", CAMP .. seg(id) .. "/archive") end
function cg:unarchive(id) return rq("POST", CAMP .. seg(id) .. "/unarchive") end
function cg:enrol(id, body) return rq("POST", CAMP .. seg(id) .. "/enrol", body) end
function cg:discover(id) return rq("POST", CAMP .. seg(id) .. "/discover") end
function cg:discover_stop(id) return rq("POST", CAMP .. seg(id) .. "/discover/stop") end
function cg:sequence_put(id, body) return rq("PUT", CAMP .. seg(id) .. "/sequence", body) end
function cg:setup_put(id, body) return rq("PUT", CAMP .. seg(id) .. "/setup", body) end
function cg:sequencer(id, body) return rq("POST", CAMP .. seg(id) .. "/sequencer", body) end
function cg:experiments(id) return rq("GET", CAMP .. seg(id) .. "/experiments") end
function cg:learning(id) return rq("GET", CAMP .. seg(id) .. "/learning") end
function cg:terminals(id) return rq("GET", CAMP .. seg(id) .. "/terminals") end
function cg:experiment_create(id, body)
return rq("POST", CAMP .. seg(id) .. "/experiments", body)
end
function c.crm.experiments:close(id)
return rq("POST", CRM .. "/experiments/" .. seg(id) .. "/close")
end
local pe = c.crm.people
function pe:list(query) return rq("GET", qs(PEOPLE, query)) end
function pe:get(id) return rq("GET", PEOPLE .. "/" .. seg(id)) end
function pe:touches(id) return rq("GET", PEOPLE .. "/" .. seg(id) .. "/touches") end
function pe:state(id, body) return rq("POST", PEOPLE .. "/" .. seg(id) .. "/state", body) end
function pe:touch(id, body) return rq("POST", PEOPLE .. "/" .. seg(id) .. "/touches", body) end
function pe:verify(body) return rq("POST", PEOPLE .. "/verify", body) end
function pe:reopen(id) return rq("POST", PEOPLE .. "/" .. seg(id) .. "/reopen") end
function pe:reopen_many(body) return rq("POST", PEOPLE .. "/reopen", body) end
function pe:import(body, opts_)
local dry = true
if opts_ and opts_.dry_run ~= nil then dry = opts_.dry_run and true or false end
return rq("POST", PEOPLE .. "/import" .. (dry and "?dry_run=1" or ""), body)
end
function pe:imports(query) return rq("GET", qs(PEOPLE .. "/imports", query)) end
function pe:import_batch(batch) return rq("GET", PEOPLE .. "/import/" .. seg(batch)) end
function pe:import_delete(batch) return rq("DELETE", PEOPLE .. "/import/" .. seg(batch)) end
function pe:import_check(sha256)
return rq("GET", PEOPLE .. "/import/check/" .. seg(sha256))
end
local ib = c.crm.inbox
function ib:list(query) return rq("GET", qs(INBOX, query)) end
function ib:thread(id) return rq("GET", INBOX .. "/threads/" .. seg(id)) end
function ib:draft_get(id) return rq("GET", INBOX .. "/drafts/" .. seg(id)) end
function ib:draft_create(body) return rq("POST", INBOX .. "/drafts", body) end
function ib:draft_update(id, body) return rq("PATCH", INBOX .. "/drafts/" .. seg(id), body) end
function ib:draft_delete(id) return rq("DELETE", INBOX .. "/drafts/" .. seg(id)) end
function ib:draft_send(id) return rq("POST", INBOX .. "/drafts/" .. seg(id) .. "/send") end
function ib:touch_draft(touch_id, body)
return rq("POST", INBOX .. "/touches/" .. seg(touch_id) .. "/draft", body)
end
function ib:touch_label(touch_id, body)
return rq("POST", INBOX .. "/touches/" .. seg(touch_id) .. "/label", body)
end
local sd = c.crm.sends
function sd:pending(query) return rq("GET", qs(CRM .. "/sends/pending", query)) end
function sd:approve(id) return rq("POST", CRM .. "/sends/" .. seg(id) .. "/approve") end
function sd:reject(id) return rq("POST", CRM .. "/sends/" .. seg(id) .. "/reject") end
function c.crm.replies:classify(email_id, body)
return rq("POST", CRM .. "/replies/" .. seg(email_id) .. "/classify", body)
end
local su = c.crm.suppressions
function su:list() return rq("GET", CRM .. "/suppressions") end
function su:add(body) return rq("POST", CRM .. "/suppressions", body) end
function su:import(body) return rq("POST", CRM .. "/suppressions/import", body) end
function su:lift(scope, value)
return rq("DELETE", CRM .. "/suppressions/" .. seg(scope) .. "/" .. seg(value))
end
local fl = c.crm.fleet
function fl:get() return rq("GET", CRM .. "/fleet") end
function fl:summary() return rq("GET", CRM .. "/fleet/summary") end
function fl:mailboxes() return rq("GET", CRM .. "/fleet/mailboxes") end
function fl:fleet_mailboxes(id) return rq("GET", CRM .. "/fleet/" .. seg(id) .. "/mailboxes") end
function fl:fleets_get() return rq("GET", CRM .. "/fleets") end
function fl:fleets_put(body) return rq("PUT", CRM .. "/fleets", body) end
local dm = c.crm.domains
function dm:list() return rq("GET", CRM .. "/domains") end
function dm:add(body) return rq("POST", CRM .. "/domains", body) end
function dm:pause(domain, body)
return rq("POST", CRM .. "/domains/" .. seg(domain) .. "/pause", body)
end
function dm:resume(domain) return rq("POST", CRM .. "/domains/" .. seg(domain) .. "/resume") end
function dm:recheck(domain, body)
return rq("POST", CRM .. "/domains/" .. seg(domain) .. "/recheck", body)
end
function dm:sync() return rq("POST", CRM .. "/domains/sync") end
local ig = c.crm.integrations
function ig:list() return rq("GET", CRM .. "/integrations") end
function ig:put(provider, body) return rq("PUT", INTEG .. seg(provider), body) end
function ig:test(provider) return rq("POST", INTEG .. seg(provider) .. "/test") end
function ig:webhook(provider) return rq("POST", INTEG .. seg(provider) .. "/webhook") end
function ig:sequences(provider) return rq("GET", INTEG .. seg(provider) .. "/sequences") end
function ig:lead_data_figures() return rq("GET", INTEG .. "lead-data/figures") end
function ig:lead_data_order_put(body) return rq("PUT", INTEG .. "lead-data/order", body) end
function ig:account_put(provider, account, body)
return rq("PUT", INTEG .. seg(provider) .. "/accounts/" .. seg(account), body)
end
function ig:account_create(provider, body)
return rq("POST", INTEG .. seg(provider) .. "/accounts", body)
end
function ig:account_update(provider, account, body)
return rq("PATCH", INTEG .. seg(provider) .. "/accounts/" .. seg(account), body)
end
function ig:account_delete(provider, account)
return rq("DELETE", INTEG .. seg(provider) .. "/accounts/" .. seg(account))
end
function ig:account_webhook_secret(provider, account)
return rq("POST", INTEG .. seg(provider) .. "/accounts/" .. seg(account) .. "/webhook-secret")
end
function ig:account_secret_delete(provider, account, key)
local tail = "/accounts/" .. seg(account) .. "/secrets/" .. seg(key)
return rq("DELETE", INTEG .. seg(provider) .. tail)
end
function ig:account_test(provider, account)
return rq("POST", INTEG .. seg(provider) .. "/accounts/" .. seg(account) .. "/test")
end
function ig:account_webhook(provider, account)
return rq("POST", INTEG .. seg(provider) .. "/accounts/" .. seg(account) .. "/webhook")
end
function ig:webhook_secret(provider)
return rq("POST", INTEG .. seg(provider) .. "/webhook-secret")
end
function ig:secret_delete(provider, key)
return rq("DELETE", INTEG .. seg(provider) .. "/secrets/" .. seg(key))
end
function c.crm.budgets:get() return rq("GET", CRM .. "/budgets") end
function c.crm.budgets:put(body) return rq("PUT", CRM .. "/budgets", body) end
function c.crm.costs:get() return rq("GET", CRM .. "/costs") end
function c.crm.deals:list(query) return rq("GET", qs(CRM .. "/deals", query)) end
function c.crm.deals:advance(id, body)
return rq("POST", CRM .. "/deals/" .. seg(id) .. "/advance", body)
end
local cl = c.crm.claims
function cl:list(query) return rq("GET", qs(CRM .. "/claims", query)) end
function cl:create(body) return rq("POST", CRM .. "/claims", body) end
function cl:update(id, body) return rq("PATCH", CRM .. "/claims/" .. seg(id), body) end
function cl:approve(id, body)
return rq("POST", CRM .. "/claims/" .. seg(id) .. "/approve", body)
end
function cl:retire(id) return rq("POST", CRM .. "/claims/" .. seg(id) .. "/retire") end
function cl:restore(id) return rq("POST", CRM .. "/claims/" .. seg(id) .. "/restore") end
local idn = c.crm.identities
function idn:list() return rq("GET", CRM .. "/identities") end
function idn:create(body) return rq("POST", CRM .. "/identities", body) end
function idn:update(body) return rq("PATCH", CRM .. "/identities", body) end
local pr = c.crm.proposals
function pr:list() return rq("GET", CRM .. "/proposals") end
function pr:accept(id) return rq("POST", CRM .. "/proposals/" .. seg(id) .. "/accept") end
function pr:decline(id) return rq("POST", CRM .. "/proposals/" .. seg(id) .. "/decline") end
local rp = c.crm.reports
function rp:conversion(query) return rq("GET", qs(CRM .. "/reports/conversion", query)) end
function rp:corrections(query) return rq("GET", qs(CRM .. "/reports/corrections", query)) end
function rp:deliverability(query)
return rq("GET", qs(CRM .. "/reports/deliverability", query))
end
function rp:rot() return rq("GET", CRM .. "/reports/rot") end
function rp:weekly_note(query) return rq("GET", qs(CRM .. "/reports/weekly-note", query)) end
function c.crm:overview() return rq("GET", CRM .. "/overview") end
function c.crm:agent() return rq("GET", CRM .. "/agent") end
function c.crm:sequencer_events(query)
return rq("GET", qs(CRM .. "/sequencer-events", query))
end
function c.crm:sequencer_event_reapply(id)
return rq("POST", CRM .. "/sequencer-events/" .. seg(id) .. "/reapply")
end
local PROJ = WORK .. "/projects/"
local ITEM = WORK .. "/items/"
local PLAN = WORK .. "/plans/"
c.work = { projects = {}, items = {}, plans = {}, sprints = {}, labels = {} }
local pj = c.work.projects
function pj:list() return rq("GET", WORK .. "/projects") end
function pj:board(id, query) return rq("GET", qs(PROJ .. seg(id) .. "/board", query)) end
function pj:meta(id, body) return rq("PATCH", PROJ .. seg(id) .. "/meta", body) end
function pj:labels(id) return rq("GET", PROJ .. seg(id) .. "/labels") end
function pj:label_add(id, body) return rq("POST", PROJ .. seg(id) .. "/labels", body) end
function pj:stages(id) return rq("GET", PROJ .. seg(id) .. "/stages") end
function pj:stages_put(id, body) return rq("PUT", PROJ .. seg(id) .. "/stages", body) end
function pj:sprints(id) return rq("GET", PROJ .. seg(id) .. "/sprints") end
function pj:sprint_add(id, body) return rq("POST", PROJ .. seg(id) .. "/sprints", body) end
function pj:plans(id) return rq("GET", PROJ .. seg(id) .. "/plans") end
function pj:plan_add(id, body) return rq("POST", PROJ .. seg(id) .. "/plans", body) end
function pj:report(id) return rq("GET", PROJ .. seg(id) .. "/report") end
function pj:timeline(id) return rq("GET", PROJ .. seg(id) .. "/timeline") end
function pj:adopt_code_refs(id, body)
return rq("POST", PROJ .. seg(id) .. "/code-host-refs/adopt", body)
end
local it = c.work.items
function it:list(query) return rq("GET", qs(WORK .. "/items", query)) end
function it:get(id) return rq("GET", ITEM .. seg(id)) end
function it:create(body) return rq("POST", WORK .. "/items", body) end
function it:update(id, body) return rq("PATCH", ITEM .. seg(id), body) end
function it:move(id, body, opts_)
if not (opts_ and opts_.unassign_sprint) then
return rq("POST", ITEM .. seg(id) .. "/move", body)
end
local copy = {}
for k, v in pairs(body or {}) do copy[k] = v end
copy.sprint_id, copy.sprintId = nil, nil
local encoded = json.encode(json.object(copy))
local head = encoded:sub(1, #encoded - 1)
local sep = #head > 1 and "," or ""
return rq("POST", ITEM .. seg(id) .. "/move", head .. sep .. '"sprint_id":null}')
end
function it:claim(id, body) return rq("POST", ITEM .. seg(id) .. "/claim", body) end
function it:comment(id, body) return rq("POST", ITEM .. seg(id) .. "/comments", body) end
function it:relate(id, body) return rq("POST", ITEM .. seg(id) .. "/relations", body) end
function it:snapshot(id) return rq("POST", ITEM .. seg(id) .. "/snapshots") end
function it:revisions(id) return rq("GET", ITEM .. seg(id) .. "/revisions") end
function it:code_link(id, body) return rq("POST", ITEM .. seg(id) .. "/code-host-link", body) end
function it:code_unlink(id) return rq("DELETE", ITEM .. seg(id) .. "/code-host-link") end
function it:unrelate(id, target_id, kind)
local path_str = ITEM .. seg(id) .. "/relations/" .. seg(target_id) .. "/" .. seg(kind)
return rq("DELETE", path_str)
end
local pl = c.work.plans
function pl:get(id) return rq("GET", PLAN .. seg(id)) end
function pl:update(id, body) return rq("PATCH", PLAN .. seg(id), body) end
function pl:submit(id) return rq("POST", PLAN .. seg(id) .. "/submit") end
function pl:decide(id, body) return rq("POST", PLAN .. seg(id) .. "/decide", body) end
function pl:signoff(id) return rq("POST", PLAN .. seg(id) .. "/signoff") end
function pl:withdraw_signoff(id) return rq("POST", PLAN .. seg(id) .. "/withdraw-signoff") end
function pl:complete(id) return rq("POST", PLAN .. seg(id) .. "/complete") end
local sp = c.work.sprints
function sp:update(id, body) return rq("PATCH", WORK .. "/sprints/" .. seg(id), body) end
function sp:close(id, body)
return rq("POST", WORK .. "/sprints/" .. seg(id) .. "/close", body)
end
local lb = c.work.labels
function lb:update(id, body) return rq("PATCH", WORK .. "/labels/" .. seg(id), body) end
function lb:delete(id) return rq("DELETE", WORK .. "/labels/" .. seg(id)) end
function c.work:ready(query) return rq("GET", qs(WORK .. "/ready", query)) end
function c.work:repositories() return rq("GET", WORK .. "/repositories") end
function c.work:import_clickup(body) return rq("POST", WORK .. "/import/clickup", body) end
return c
end
return M