Compare commits

..

8 Commits

Author SHA1 Message Date
bryanthaboi d87f6b8ad1 Merge pull request #1357 from anxiousintrovert/agent/fix-launcher-versioned-conflicts
Fix versioned mod conflicts in launcher
2026-08-15 16:51:52 -04:00
bryanthaboi 24cf367758 Merge pull request #1363 from ShaneMcGovernIE/feat/fetch-postlog
feat(mods): mod.postLog — one-way log reporting for mod owners
2026-08-15 16:51:33 -04:00
bryanthaboi 829d398a94 Merge pull request #1365 from bryanthaboi/dependabot/github_actions/actions/setup-dotnet-6
build(deps): bump actions/setup-dotnet from 4 to 6
2026-08-15 16:51:14 -04:00
bryanthaboi 3ee50a27c5 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-15 16:50:43 -04:00
bryanthaboi 871087a16b Delete timekeepers_hut 2026-08-15 16:50:40 -04:00
dependabot[bot] 180ce6b2e7 build(deps): bump actions/setup-dotnet from 4 to 6
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-15 20:32:54 +00:00
Shane McGovern cf335f67de feat(mods): add mod.postLog one-way log reporting to a manifest-declared URL 2026-08-15 21:03:24 +01:00
anxiousintrovert b8ec4fe6b5 Fix versioned mod conflicts in launcher 2026-08-15 13:17:04 -05:00
13 changed files with 434 additions and 46 deletions
+1 -1
View File
@@ -262,7 +262,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- name: Setup .NET 8 - name: Setup .NET 8
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v6
with: with:
dotnet-version: "8.0.x" dotnet-version: "8.0.x"
- name: Publish gen1tls (win-x64 Native AOT) - name: Publish gen1tls (win-x64 Native AOT)
+34
View File
@@ -90,6 +90,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. | | `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. |
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. | | `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). | | `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
| `log_url` | `string` | Optional https URL for `mod.postLog` log reporting (api 2; requires the `network` permission). |
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. | | `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
### Declaring Dependencies & Scoping ### Declaring Dependencies & Scoping
@@ -864,6 +865,39 @@ with a full standard library that the sandbox cannot reach, so handing one
to a mod would undo every other rule; `mod.fetch`'s workers run engine to a mod would undo every other rule; `mod.fetch`'s workers run engine
code, so a mod gets asynchrony without gaining any new reach. code, so a mod gets asynchrony without gaining any new reach.
## Log reporting
`mod.postLog(body, opts)` is the one-way exception to the rule that a mod
decides where it talks. It reports a debug/crash log to the https URL the
manifest declares in `log_url`, and it is the only API that may not be
pointed at a caller-chosen address:
```json
{
"permissions": ["network"],
"log_url": "https://logs.example.com/receive"
}
```
The URL is validated at load: it must be `https://`, and declaring it
without the `network` permission is a load violation for api 2 mods. The
destination is reviewed when the mod ships, not chosen per call, so a mod
cannot aim this at arbitrary hosts or read back anything a server replies.
```lua
-- fire and forget; poll() never blocks, same shape as mod.fetch
local job = mod:postLog("session crashed at 0x1f3a\n" .. logText)
```
`postLog(body, opts)` returns the same opaque handle as `mod.fetch:get`,
polled and released through `mod.fetch:poll` / `mod.fetch:release`. `opts`
is a closed list with one switch: `format`, either `"text"` (the default)
or `"json"`. `json` wraps the body in an envelope of `{ ts, mod, format,
body }` so a server can attribute and sort reports; any other key or value
is refused before a job is submitted. The body is capped at 64 KB, the
transfer is bounded by the same worker ceilings as `mod.fetch`, and the
response body is never returned to the mod.
## Background jobs ## Background jobs
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on `mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
-42
View File
@@ -12,48 +12,6 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.1.92",
"date": "2026-08-15",
"size": 11364274,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.92/gen1recomp++-0.1.92-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.91",
"date": "2026-08-15",
"size": 11351477,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.91/gen1recomp++-0.1.91-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.90",
"date": "2026-08-15",
"size": 11344338,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.90/gen1recomp++-0.1.90-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
},
{
"version": "0.1.89",
"date": "2026-08-15",
"size": 11343841,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.89/gen1recomp++-0.1.89-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi"
},
{
"version": "0.1.88",
"date": "2026-08-14",
"size": 11311237,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.88/gen1recomp++-0.1.88-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.87",
"date": "2026-08-14",
"size": 11310910,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.87/gen1recomp++-0.1.87-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @mleo2003\n- @ShaneMcGovernIE\n- MaxTomahawk"
},
{ {
"version": "0.1.86", "version": "0.1.86",
"date": "2026-08-14", "date": "2026-08-14",
-1
View File
@@ -1 +0,0 @@
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
+52
View File
@@ -417,4 +417,56 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
return body return body
end end
-- POST returning success/failure. Strictly one-way: the response body is
-- discarded, only the HTTP status class is surfaced (postLog callers never
-- trust the reply). curl --data-binary reads the payload from a pipe, so a
-- large body never lands in the command line; the Android bridge has no POST
-- transport, and httpPost reports that instead of half-working through
-- httpDownload (a GET round-trip to a POST endpoint would be a lie).
function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
if type(url) ~= "string" or url == "" then return nil, "missing url" end
if type(body) ~= "string" then return nil, "missing body" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
-- --data-binary @- keeps the payload out of argv (command-line length
-- limits on Windows) and preserves every byte including trailing
-- newlines. No -f, matching httpGet: the response body is discarded
-- anyway, and curl's stderr carries the real diagnosis on failure.
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
.. "-X POST "
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if contentType then
cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " "
end
cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " "
.. "--data-binary @- "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd, "rw")
if not pipe then return nil, "could not run curl" end
local writeOk, werr = pcall(pipe.write, pipe, body)
if not writeOk then
HostShell.pclose(pipe)
return nil, "could not write body: " .. tostring(werr)
end
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
if not readOk then
return nil, fetchError(url, nil, tostring(out))
end
local _, status, noise = splitCurlOutput(out)
if not status then return nil, fetchError(url, nil, noise) end
if status < 200 or status >= 300 then
return nil, fetchError(url, status, "log post rejected")
end
return true
end
if not haveBridge() then
return nil, "no network transport on this platform"
end
return nil, "no POST transport on this platform"
end
return HostShell return HostShell
+10 -2
View File
@@ -215,12 +215,19 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
return m and not m.experimental return m and not m.experimental
end end
local function conflictApplies(spec, other)
return not spec.range or (other and other.version
and Semver.satisfies(other.version, spec.range))
end
-- (a) Conflicts declared by target manifest -- (a) Conflicts declared by target manifest
if type(manifest.conflictSpecs) == "table" then if type(manifest.conflictSpecs) == "table" then
for _, spec in ipairs(manifest.conflictSpecs) do for _, spec in ipairs(manifest.conflictSpecs) do
local conflictId = spec.id local conflictId = spec.id
local installedOther = installedMap[conflictId] local installedOther = installedMap[conflictId]
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then if installedOther and isEnabled(conflictId)
and conflictApplies(spec, installedOther)
and not conflictIdsSeen[conflictId] then
conflictIdsSeen[conflictId] = true conflictIdsSeen[conflictId] = true
hasIssues = true hasIssues = true
depsResult[#depsResult + 1] = { depsResult[#depsResult + 1] = {
@@ -238,11 +245,12 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
-- (b) Reverse conflicts declared by installed mods against target manifest -- (b) Reverse conflicts declared by installed mods against target manifest
if manifest.id then if manifest.id then
local installedTarget = installedMap[manifest.id] or manifest
for _, other in ipairs(manifests) do for _, other in ipairs(manifests) do
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
local conflicts = other.conflictSpecs or {} local conflicts = other.conflictSpecs or {}
for _, spec in ipairs(conflicts) do for _, spec in ipairs(conflicts) do
if spec.id == manifest.id then if spec.id == manifest.id and conflictApplies(spec, installedTarget) then
conflictIdsSeen[other.id] = true conflictIdsSeen[other.id] = true
hasIssues = true hasIssues = true
depsResult[#depsResult + 1] = { depsResult[#depsResult + 1] = {
+17
View File
@@ -1094,6 +1094,23 @@ function Loader:_api(mod)
return { available = function() return false end, return { available = function() return false end,
get = refuse, poll = refuse, release = refuse, cancel = refuse } get = refuse, poll = refuse, release = refuse, cancel = refuse }
end)(), end)(),
-- One-way crash-log reporting to the https URL the manifest declares in
-- log_url. The destination is reviewed at load, not chosen per call, so
-- a mod cannot aim this at arbitrary hosts; the response body is never
-- returned, and the worker pool bounds the transfer. Same handle/poll/
-- release shape as mod.fetch, so mod.job's sibling patterns carry over.
postLog = (function()
if mod.manifest.permissionSet.network and mod.manifest.log_url then
return function(_, body, opts)
return Net.postLog(loader, modId, mod.manifest.log_url, body, opts)
end
end
local function refuse()
error(('[%s] mod.postLog needs the "network" permission and a '
.. "log_url in manifest.json"):format(modId), 2)
end
return refuse
end)(),
-- Background compute, behind the "background" permission. The worker -- Background compute, behind the "background" permission. The worker
-- rebuilds this mod's sandbox before loading the script, so a job is the -- rebuilds this mod's sandbox before loading the script, so a job is the
-- one thing love.thread is not: off the main thread without a Lua state -- one thing love.thread is not: off the main thread without a Lua state
+19
View File
@@ -308,6 +308,24 @@ function Manifest.validate(raw, path)
local github = Manifest.parseGithub(raw.github) local github = Manifest.parseGithub(raw.github)
-- log_url: the mod's one-way crash-log reporting destination. https-only,
-- declared in the manifest so the engine reviews the target at load instead
-- of trusting per-call URLs from gameplay code, and gated on the `network`
-- permission the mod must also declare. api 1 mods never carry it: it is a
-- load violation, not a warning, because a postLog-capable mod that does not
-- opt in to networking is a bug in the manifest itself.
local logUrl = nil
if raw.log_url ~= nil then
if strict and not permissionSet.network then
violation(strict, raw.id, "log_url requires the network permission")
elseif strict and (type(raw.log_url) ~= "string"
or not raw.log_url:match("^https://")) then
violation(strict, raw.id, "log_url must be an https:// URL")
elseif strict then
logUrl = raw.log_url
end
end
assert(raw.experimental == nil or type(raw.experimental) == "boolean", assert(raw.experimental == nil or type(raw.experimental) == "boolean",
"experimental must be a boolean") "experimental must be a boolean")
local experimental = raw.experimental == true local experimental = raw.experimental == true
@@ -414,6 +432,7 @@ function Manifest.validate(raw, path)
affects_link = affectsLink, affects_link = affectsLink,
permissions = permissions, permissions = permissions,
permissionSet = permissionSet, permissionSet = permissionSet,
log_url = logUrl,
options_schema = optionalFile(raw.options_schema, "options_schema"), options_schema = optionalFile(raw.options_schema, "options_schema"),
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
required_imports = requiredImports, required_imports = requiredImports,
+59
View File
@@ -32,6 +32,9 @@ local Net = {}
Net.MAX_INFLIGHT = 4 Net.MAX_INFLIGHT = 4
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely. -- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
Net.MAX_SECONDS = 30 Net.MAX_SECONDS = 30
-- A log body ceiling. Debug logs are kilobytes, and a server operator has no
-- reason to accept a mod uploading arbitrary megabytes to its endpoint.
Net.MAX_BODY = 65536
local function fetch() local function fetch()
return require("src.net.Fetch") return require("src.net.Fetch")
@@ -99,6 +102,62 @@ function Net.get(loader, modId, url, opts)
return handle return handle
end end
-- The closed list of postLog format switches. Anything outside it is a
-- caller bug, rejected before a job is submitted, so the surface stays
-- exactly two shapes on the wire.
local POST_FORMATS = { text = true, json = true }
-- A one-way log POST to the mod's manifest-declared log_url (https only,
-- validated in Manifest.lua). Same shape as get(): opaque handle, per-mod
-- in-flight ceiling, user agent naming the mod. The response body is never
-- returned -- a postLog is fire-and-forget reporting, and the engine has no
-- reason to hand a mod a server's reply.
function Net.postLog(loader, modId, logUrl, body, opts)
if type(body) ~= "string" or body == "" then
return nil, "log body must be a non-empty string"
end
if #body > Net.MAX_BODY then
return nil, ("log body too large (%d bytes, limit %d)"):format(#body, Net.MAX_BODY)
end
opts = type(opts) == "table" and opts or {}
for key in pairs(opts) do
if key ~= "format" then
return nil, ("unknown log option %q (format is the only switch)"):format(tostring(key))
end
end
local format = opts.format or "text"
if not POST_FORMATS[format] then
return nil, ("unknown log format %q (text and json only)"):format(tostring(format))
end
local denial = Net.urlDenial(logUrl)
if denial then return nil, denial end
local b = bucket(loader, modId)
if inflight(b) >= Net.MAX_INFLIGHT then
return nil, ("too many requests in flight (limit %d); poll and release "
.. "the ones you have"):format(Net.MAX_INFLIGHT)
end
local payload = body
local contentType = "text/plain"
if format == "json" then
local Json = require("src.link.Json")
payload = Json.encode({
ts = os.time(),
mod = modId,
format = "json",
body = body,
})
contentType = "application/json"
end
local id = fetch().post(logUrl, payload, {
userAgent = "gen1recomp-mod/" .. tostring(modId),
contentType = contentType,
maxSeconds = Net.MAX_SECONDS,
})
local handle = {}
b[handle] = id
return handle
end
-- A copy of the job's state, never the engine's own table. An unknown or -- A copy of the job's state, never the engine's own table. An unknown or
-- forged handle reads as an error rather than nil, so a mod that lost track of -- forged handle reads as an error rather than nil, so a mod that lost track of
-- one cannot spin waiting on it forever. -- one cannot spin waiting on it forever.
+11
View File
@@ -132,6 +132,17 @@ function Fetch.get(url, opts)
accept = opts.accept, maxSeconds = opts.maxSeconds }) accept = opts.accept, maxSeconds = opts.maxSeconds })
end end
-- POST a body to a URL, one-way. The result carries no body: postLog
-- reporting never trusts a server's reply, so the worker surfaces only
-- ok/error and the transport's complaint.
-- opts: { userAgent, contentType, maxSeconds }
function Fetch.post(url, body, opts)
opts = opts or {}
return submit({ kind = "post", url = url, body = body,
userAgent = opts.userAgent or "gen1recomp",
contentType = opts.contentType, maxSeconds = opts.maxSeconds })
end
-- Download a URL to `saveRel`, a path relative to the LOVE save directory. -- Download a URL to `saveRel`, a path relative to the LOVE save directory.
-- Progress is reported as a 0..1 fraction when `size` is known. -- Progress is reported as a 0..1 fraction when `size` is known.
function Fetch.download(url, saveRel, opts) function Fetch.download(url, saveRel, opts)
+17
View File
@@ -99,6 +99,20 @@ local function doDownload(job)
post({ id = job.id, ok = true, path = rel, done = true }) post({ id = job.id, ok = true, path = rel, done = true })
end end
local function doPost(job)
if not HostShell then
post({ id = job.id, ok = false, err = "no transport" })
return
end
local ok, err = HostShell.httpPost(job.url, job.body, job.contentType,
job.userAgent, tonumber(job.maxSeconds) or GET_MAX_SECONDS)
if not ok then
post({ id = job.id, ok = false, err = err or "post failed" })
return
end
post({ id = job.id, ok = true, done = true })
end
while true do while true do
local job = cmdCh:demand() local job = cmdCh:demand()
-- The flag is checked before the job's KIND, so a worker woken by a -- The flag is checked before the job's KIND, so a worker woken by a
@@ -114,6 +128,9 @@ while true do
elseif job.kind == "get" then elseif job.kind == "get" then
local ok, err = pcall(doGet, job) local ok, err = pcall(doGet, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
elseif job.kind == "post" then
local ok, err = pcall(doPost, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
elseif job.kind == "download" then elseif job.kind == "download" then
local ok, err = pcall(doDownload, job) local ok, err = pcall(doDownload, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
+45
View File
@@ -550,6 +550,51 @@ local installedColorlib = Manifest.validate({
version = "1.0.0", version = "1.0.0",
entry = "main.lua", entry = "main.lua",
}, "mods/colorlib") }, "mods/colorlib")
local unconditionalConflict = LauncherMods.checkDependencies(testTargetManifest,
nil, nil, { testTargetManifest, installedColorlib })
check(unconditionalConflict.hasIssues == true,
"dependency resolver reports an unversioned conflict")
local function rangeTarget(version, conflicts)
return Manifest.validate({
id = "range_target",
name = "Range Target",
version = version,
entry = "main.lua",
conflicts = conflicts or {},
}, "mods/range_target")
end
local function rangeSource(conflicts)
return Manifest.validate({
id = "range_source",
name = "Range Source",
version = "1.0.0",
entry = "main.lua",
conflicts = conflicts or {},
}, "mods/range_source")
end
local forwardSource = rangeSource({ "range_target@<2.0.0" })
local matchingTarget = rangeTarget("1.4.0")
local nonmatchingTarget = rangeTarget("2.0.0")
local forwardMatching = LauncherMods.checkDependencies(forwardSource,
nil, nil, { forwardSource, matchingTarget })
check(forwardMatching.hasIssues == true and #forwardMatching.deps == 1,
"dependency resolver applies a matching forward conflict range")
local forwardNonmatching = LauncherMods.checkDependencies(forwardSource,
nil, nil, { forwardSource, nonmatchingTarget })
check(forwardNonmatching.hasIssues == false and #forwardNonmatching.deps == 0,
"dependency resolver ignores a nonmatching forward conflict range")
local reverseSource = rangeSource({ "range_target@<2.0.0" })
local reverseMatching = LauncherMods.checkDependencies(matchingTarget,
nil, nil, { reverseSource, matchingTarget })
check(reverseMatching.hasIssues == true and #reverseMatching.deps == 1,
"dependency resolver applies a matching reverse conflict range")
local reverseNonmatching = LauncherMods.checkDependencies(nonmatchingTarget,
nil, nil, { reverseSource, nonmatchingTarget })
check(reverseNonmatching.hasIssues == false and #reverseNonmatching.deps == 0,
"dependency resolver ignores a nonmatching reverse conflict range")
-- ------- scoped dependency tests -- ------- scoped dependency tests
local Json = require("src.link.Json") local Json = require("src.link.Json")
local scopedDepManifest = Manifest.validate({ local scopedDepManifest = Manifest.validate({
+169
View File
@@ -0,0 +1,169 @@
-- mod.postLog: one-way log reporting to the manifest-declared log_url.
-- The things this pins are the strict ones -- https-only destination that
-- lives in the manifest (not per-call), a closed list of format switches,
-- a body ceiling, opaque per-mod handles, and refusal without the network
-- permission.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Net = require("src.mods.Net")
local Fetch = require("src.net.Fetch")
-- Stand in for the worker pool: jobs resolve when the test says so, so no
-- test here touches a socket.
local submitted, nextId, states = {}, 0, {}
Fetch.post = function(url, body, opts)
nextId = nextId + 1
submitted[nextId] = { url = url, body = body, opts = opts }
states[nextId] = { status = "pending", progress = 0 }
return nextId
end
Fetch.poll = function(id) return states[id] or { status = "error", err = "unknown job" } end
Fetch.isPending = function(id) return (states[id] or {}).status == "pending" end
Fetch.release = function(id) states[id] = nil end
Fetch.cancel = function(id)
if states[id] and states[id].status == "pending" then states[id].status = "cancelled" end
end
Fetch.available = function() return true end
local LOGGER = {
["mods/log_sender/manifest.json"] = [[{
"id": "log_sender",
"name": "Log Sender",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"permissions": ["network"],
"log_url": "https://logs.example.com/logs"
}]],
["mods/log_sender/main.lua"] = [[
local mod = ...
mod.exports.send = function(body, opts)
local handle, err = mod:postLog(body, opts)
if not handle then return nil, err end
return handle
end
mod.exports.poll = function(h) return mod.fetch:poll(h) end
mod.exports.release = function(h) return mod.fetch:release(h) end
]],
}
local function manifest(id, extra)
return ('{"id": "%s", "name": "T", "version": "1.0.0", "entry": "main.lua", '
.. '"api": 2%s}'):format(id, extra or "")
end
local NO_URL = {
["mods/log_no_url/manifest.json"] = manifest("log_no_url", ', "permissions": ["network"]'),
["mods/log_no_url/main.lua"] = [[
local mod = ...
mod.exports.try = function()
local ok, err = pcall(function() return mod:postLog("body") end)
return ok, err
end
]],
}
-- ------------------------------------------------ the closed opts list
local run = T.sdk.loadMods({ "mods/log_sender" }, { fs = T.sdk.memfs(LOGGER) })
T.eq(#run.errors, 0, "the logger mod loads clean (" .. tostring(run.errors[1]) .. ")")
local api = run.loader.exports.log_sender
local bad, badErr = api.send("body", { format = "xml" })
T.eq(bad, nil, "an unknown format is refused")
T.check(badErr and badErr:find("text and json only", 1, true), "and names the allowed ones")
local badKey, keyErr = api.send("body", { envelope = true })
T.eq(badKey, nil, "an unknown opt key is refused")
T.check(keyErr and keyErr:find("format is the only switch", 1, true), "and says so")
-- ------------------------------------------------ body validation
local empty, emptyErr = api.send("")
T.eq(empty, nil, "an empty body is refused")
local big = string.rep("x", Net.MAX_BODY + 1)
local bigH, bigErr = api.send(big)
T.eq(bigH, nil, "an oversized body is refused")
T.check(bigErr and bigErr:find("limit", 1, true), "and names the limit")
-- ------------------------------------------------- text format (default)
local handle, err = api.send("hello log")
T.check(handle ~= nil, "a plain text post returns a handle (" .. tostring(err) .. ")")
T.eq(type(handle), "table", "the handle is opaque, not the engine's job id")
T.eq(api.poll(handle).status, "pending", "a fresh post polls as pending")
local sent
for _, job in pairs(submitted) do
if job.url == "https://logs.example.com/logs" and job.body == "hello log" then sent = job end
end
T.check(sent ~= nil, "the post reached the pool with the manifest URL")
T.eq(sent.opts.contentType, "text/plain", "plain text posts as text/plain")
T.check(sent.opts.userAgent:find("log_sender", 1, true),
"the request identifies the calling mod")
-- -------------------------------------------------- json format
local jh, jerr = api.send("line one", { format = "json" })
T.check(jh ~= nil, "a json post returns a handle (" .. tostring(jerr) .. ")")
local jsent
for _, job in pairs(submitted) do
if job.opts.contentType == "application/json" then jsent = job end
end
T.check(jsent ~= nil, "json posts as application/json")
local decoded = require("src.link.Json").decode(jsent.body)
T.eq(type(decoded), "table", "the json body is a table")
T.eq(decoded.format, "json", "the envelope names its format")
T.eq(decoded.mod, "log_sender", "the envelope names the mod")
T.eq(decoded.body, "line one", "the payload survives the envelope")
-- completion flows through poll, like get
states[1] = { status = "ok", progress = 1 }
local got = api.poll(handle)
T.eq(got.status, "ok", "a completed post polls ok")
api.release(handle)
run.release()
-- --------------------------------------- manifest without log_url refuses
local nurl = T.sdk.loadMods({ "mods/log_no_url" }, { fs = T.sdk.memfs(NO_URL) })
T.eq(#nurl.errors, 0, "no log_url loads clean (" .. tostring(nurl.errors[1]) .. ")")
local okCall, callErr = nurl.loader.exports.log_no_url.try()
T.check(not okCall and callErr:find("log_url", 1, true),
"postLog without log_url names the missing manifest field")
nurl.release()
-- ------------------------------------------- manifest validation: the gate
-- log_url without the network permission is a load violation in a strict
-- manifest: the mod declares a network capability it did not opt in to. The
-- violation fires inside manifest validation, so the mod never enters
-- loader.mods at all.
local badManifest = T.sdk.loadMods({ "mods/log_bad" }, { fs = T.sdk.memfs({
["mods/log_bad/manifest.json"] = manifest("log_bad",
', "log_url": "https://logs.example.com/logs"'),
["mods/log_bad/main.lua"] = "local mod = ...",
}) })
T.eq(badManifest.mods.log_bad, nil,
"log_url without network: the mod is refused before load")
-- a non-https log_url is refused even with the permission
local httpManifest = T.sdk.loadMods({ "mods/log_http" }, { fs = T.sdk.memfs({
["mods/log_http/manifest.json"] = manifest("log_http",
', "permissions": ["network"], "log_url": "http://logs.example.com/logs"'),
["mods/log_http/main.lua"] = "local mod = ...",
}) })
T.eq(httpManifest.mods.log_http, nil,
"an http log_url: the mod is refused before load")
-- an api 1 manifest carries no strict surface: log_url is ignored, and the
-- mod loads (its postLog call still refuses -- there is no log_url to use)
local api1 = T.sdk.loadMods({ "mods/log_api1" }, { fs = T.sdk.memfs({
["mods/log_api1/manifest.json"] = [[{
"id": "log_api1", "name": "T", "version": "1.0.0", "entry": "main.lua",
"api": 1, "log_url": "https://logs.example.com/logs"
}]],
["mods/log_api1/main.lua"] = "local mod = ...",
}) })
T.eq(#api1.errors, 0, "an api 1 manifest ignores log_url ("
.. tostring(api1.errors[1]) .. ")")
T.check(api1.loader.mods.log_api1 ~= nil, "and the mod loads")
T.finish("mod_postlog")