From 0fb4072ace1d9638ce2dd7976bba3d986a652a1e Mon Sep 17 00:00:00 2001 From: cubap Date: Fri, 18 Sep 2026 11:27:44 -0500 Subject: [PATCH 1/9] feat: passthrough token support for machine-to-machine writes A registered application can now send its own Authorization header to any TinyNode CRUD route and have it forwarded to RERUM verbatim, so the write is attributed to the caller's agent instead of the instance's. This gives small annotation apps an auth-wrapping backend call without cloning TinyThings. - routes/helpers/passthrough.js: resolveAuthorization picks the caller's header when present, requirePassthroughAllowed rejects with 403 when an operator sets ALLOW_PASSTHROUGH_TOKENS=false (no silent misattribution) - tokens.js: checkAccessToken skips the instance refresh cycle for passthrough requests, so a failed refresh cannot fail a request that does not use the instance token - all routes: pass upstream 401/403 through with their real status codes so callers can debug their own tokens; other failures remain 502 - docs: README Passthrough Token Mode section, sample.env flag, TESTING.md passthrough coverage notes - openapi: bearerAuth security scheme, artifact bumped to 0.2.0-alpha.1 Closes #133 --- README.md | 20 +++ .../tinynode-shared-components.openapi.yaml | 14 +- routes/create.js | 19 +- routes/delete.js | 33 +++- routes/helpers/passthrough.js | 66 +++++++ routes/overwrite.js | 16 +- routes/query.js | 16 +- routes/update.js | 16 +- sample.env | 6 +- test/TESTING.md | 35 ++++ test/routes/create.test.js | 109 ++++++++++++ test/routes/delete.test.js | 62 +++++++ test/routes/openapi_sync_artifacts.test.js | 20 +++ test/routes/overwrite.test.js | 48 +++++ test/routes/passthrough.test.js | 167 ++++++++++++++++++ test/routes/query.test.js | 47 +++++ test/routes/update.test.js | 65 +++++++ tokens.js | 8 + 18 files changed, 751 insertions(+), 16 deletions(-) create mode 100644 routes/helpers/passthrough.js create mode 100644 test/routes/passthrough.test.js diff --git a/README.md b/README.md index 62ce8a1..cdff251 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,26 @@ OPEN_API_CORS = true **Note:** If you leave `index.html` in the app code it is possible that users will be able to navigate to this page and experience a front end. This is either a bug or a feature...if you don't want users to end up in a front end then remove or rename the `index.html` file. +### Passthrough Token Mode +A registered application can make machine-to-machine calls through a running TinyNode without cloning the whole thing. Send your own registered access token in the `Authorization` header of any `/create`, `/update`, `/overwrite`, `/delete`, or `/query` request: + +```shell +curl -X POST https://tiny.rerum.io/create \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"@type": "oa:Annotation", "body": "…"}' +``` + +Your token is forwarded to RERUM verbatim, so the write is attributed to your registered agent instead of the TinyNode instance's agent. TinyNode does not validate the token; RERUM rejects it with a `401` if it is bad or expired, and that response is passed back to you unchanged. Any error other than `401`/`403` is reported as a `502` from TinyNode. + +This mode is on by default. An operator who wants the instance to write only with its own identity can disable it in `.env`: + +```shell +ALLOW_PASSTHROUGH_TOKENS = false +``` + +When disabled, a request that carries an `Authorization` header is rejected with `403` rather than silently attributing the write to the instance's agent. Requests without the header are unaffected and keep using the instance's identity. + # 🌟👍 Contributors 👍🌟 Trying to contribute to the public TinyThings? No way, that's awesome. Read the [Contributors Guide](CONTRIBUTING.md)! diff --git a/openapi/components/tinynode-shared-components.openapi.yaml b/openapi/components/tinynode-shared-components.openapi.yaml index 1d50249..05d3806 100644 --- a/openapi/components/tinynode-shared-components.openapi.yaml +++ b/openapi/components/tinynode-shared-components.openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: TinyNode Shared Components - version: 0.1.0-alpha.1 + version: 0.2.0-alpha.1 description: >- Shared schema components reused by multiple TinyNode seams. TinyNode route handlers are sourced from CenterForDigitalHumanities/TinyNode routes/ @@ -14,6 +14,18 @@ x-upstream-provider: name: rerum_server_nodejs baseUrl: https://store.rerum.io/v1 components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: >- + Caller-supplied RERUM access token. When present, TinyNode forwards it + upstream verbatim and the write is attributed to the caller's + registered agent instead of the instance's agent. TinyNode does not + validate the token; RERUM rejects invalid tokens with 401, which + TinyNode passes through unchanged. Operators can reject passthrough + with ALLOW_PASSTHROUGH_TOKENS=false, which answers 403 for any request + carrying this header. schemas: HealthResponse: type: object diff --git a/routes/create.js b/routes/create.js index 4bc03cc..797ace7 100644 --- a/routes/create.js +++ b/routes/create.js @@ -1,11 +1,12 @@ import express from "express" import checkAccessToken from "../tokens.js" -import { verifyJsonContentType } from "../rest.js" +import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" +import { requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() /* POST a create to the thing. */ -router.post('/', verifyJsonContentType, checkAccessToken, async (req, res, next) => { +router.post('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessToken, async (req, res, next) => { try { // if an id is passed in, pop off the end to make it an _id @@ -21,7 +22,7 @@ router.post('/', verifyJsonContentType, checkAccessToken, async (req, res, next) headers: { 'user-agent': 'Tiny-Things/1.0', 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, + 'Authorization': resolveAuthorization(req), 'Content-Type' : "application/json;charset=utf-8" } } @@ -29,6 +30,18 @@ router.post('/', verifyJsonContentType, checkAccessToken, async (req, res, next) const rerumResponse = await fetchRerum(createURL, createOptions) .then(async (resp) => { if (resp.ok) return resp.json() + // Pass through 401/403 so callers can see RERUM's rejection of their + // own token rather than a misleading 502. Everything else is a bad + // gateway from TinyNode's point of view. + if (resp.status === 401 || resp.status === 403) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${createURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${createURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/delete.js b/routes/delete.js index 4bc3912..4ad3937 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -1,11 +1,12 @@ import express from "express" import checkAccessToken from "../tokens.js" -import { verifyJsonContentType } from "../rest.js" +import { httpError, verifyJsonContentType } from "../rest.js" import { fetchRerum } from "../rerum.js" +import { requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() /* Legacy delete pattern w/body. */ -router.delete('/', verifyJsonContentType, checkAccessToken, async (req, res, next) => { +router.delete('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessToken, async (req, res, next) => { try { if (!req?.body || !(req.body['@id'] ?? req.body.id)) { const err = new Error("No record id to delete! (https://store.rerum.io/v1/API.html#delete)") @@ -20,7 +21,7 @@ router.delete('/', verifyJsonContentType, checkAccessToken, async (req, res, nex headers: { 'user-agent': 'Tiny-Things/1.0', 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, + 'Authorization': resolveAuthorization(req), 'Content-Type' : "application/json; charset=utf-8" } } @@ -28,6 +29,17 @@ router.delete('/', verifyJsonContentType, checkAccessToken, async (req, res, nex await fetchRerum(deleteURL, deleteOptions) .then(async (resp) => { if (resp.ok) return + // Pass through 401/403 so callers see RERUM's rejection of their own + // token instead of a misleading 502. + if (resp.status === 401 || resp.status === 403) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${deleteURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${deleteURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } let rerumErrorMessage try { rerumErrorMessage = `${resp.status ?? 500}: ${deleteURL} - ${await resp.text()}` @@ -47,7 +59,7 @@ router.delete('/', verifyJsonContentType, checkAccessToken, async (req, res, nex }) /* DELETE an object by ID via the RERUM API. */ -router.delete('/:id', checkAccessToken, async (req, res, next) => { +router.delete('/:id', requirePassthroughAllowed, checkAccessToken, async (req, res, next) => { try { const deleteURL = `${process.env.RERUM_API_ADDR}delete/${req.params.id}` @@ -56,12 +68,23 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => { headers: { 'user-agent': 'Tiny-Things/1.0', 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, + 'Authorization': resolveAuthorization(req), } } await fetchRerum(deleteURL, deleteOptions) .then(async (resp) => { if (resp.ok) return + // Pass through 401/403 so callers see RERUM's rejection of their own + // token instead of a misleading 502. + if (resp.status === 401 || resp.status === 403) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${deleteURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${deleteURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/helpers/passthrough.js b/routes/helpers/passthrough.js new file mode 100644 index 0000000..7e4b328 --- /dev/null +++ b/routes/helpers/passthrough.js @@ -0,0 +1,66 @@ +import { httpError } from "../../rest.js" + +/** + * Whether this TinyNode instance accepts caller-provided Authorization + * headers (token passthrough). Defaults to true so a deployed instance + * can act as a machine-to-machine relay for registered RERUM agents, + * matching the issue's intent. Set ALLOW_PASSTHROUGH_TOKENS=false to + * force every write to use this instance's own identity. + */ +export function isPassthroughAllowed() { + return process.env.ALLOW_PASSTHROUGH_TOKENS !== "false" +} + +/** + * The Authorization header value the request should carry upstream. + * When the caller provided one and passthrough is allowed, their value + * replaces this instance's ACCESS_TOKEN. Otherwise the instance's + * default identity is used. + * + * @param {import("express").Request} req + * @returns {string} the full Authorization header value, e.g. "Bearer ..." + */ +export function resolveAuthorization(req) { + const incoming = req?.headers?.authorization + if (incoming && isPassthroughAllowed()) { + return incoming + } + return `Bearer ${process.env.ACCESS_TOKEN}` +} + +/** + * Middleware guard for token passthrough. When passthrough is disabled + * and a caller supplies an Authorization header, reject the request + * rather than silently attributing the write to this instance's agent. + * The caller's token is not validated here; RERUM is the authority. + * + * @param {import("express").Request} req + * @param {import("express").Response} res + * @param {import("express").NextFunction} next + */ +export function requirePassthroughAllowed(req, res, next) { + try { + if (req?.headers?.authorization && !isPassthroughAllowed()) { + throw httpError( + "Token passthrough is not allowed on this TinyNode instance. Remove the Authorization header to act as this instance's agent.", + 403 + ) + } + next() + } + catch (err) { + next(err) + } +} + +/** + * Whether the incoming request carries a caller-provided Authorization + * header that will be honored upstream. Used by checkAccessToken to + * skip the instance token refresh cycle for requests that do not use it. + * + * @param {import("express").Request} req + * @returns {boolean} + */ +export function isPassthroughRequest(req) { + return Boolean(req?.headers?.authorization && isPassthroughAllowed()) +} diff --git a/routes/overwrite.js b/routes/overwrite.js index 7105cbf..3674096 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -2,10 +2,11 @@ import express from "express" import checkAccessToken from "../tokens.js" import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" +import { requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() /* PUT an overwrite to the thing. */ -router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) => { +router.put('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessToken, async (req, res, next) => { try { @@ -21,7 +22,7 @@ router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) headers: { 'user-agent': 'Tiny-Things/1.0', 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, + 'Authorization': resolveAuthorization(req), 'Content-Type' : "application/json;charset=utf-8" } } @@ -50,6 +51,17 @@ router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) err.body = conflictBody throw err } + // Pass through 401/403 so callers see RERUM's rejection of their own + // token instead of a misleading 502. + if (resp.status === 401 || resp.status === 403) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${overwriteURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${overwriteURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/query.js b/routes/query.js index 98d7057..f996b3e 100644 --- a/routes/query.js +++ b/routes/query.js @@ -1,12 +1,13 @@ import express from "express" import { httpError, verifyJsonContentType } from "../rest.js" import { fetchRerum } from "../rerum.js" +import { requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() const PAGINATION_HEADERS = ["Pagination-Limit", "Pagination-Skip", "Pagination-Limit-Max", "Pagination-Skip-Max"] /* POST a query to the thing. */ -router.post('/', verifyJsonContentType, async (req, res, next) => { +router.post('/', verifyJsonContentType, requirePassthroughAllowed, async (req, res, next) => { const lim = req.query.limit ?? 10 const skip = req.query.skip ?? 0 @@ -31,7 +32,7 @@ router.post('/', verifyJsonContentType, async (req, res, next) => { headers: { 'user-agent': 'Tiny-Things/1.0', 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, // not required for query + 'Authorization': resolveAuthorization(req), // not required for query 'Content-Type' : "application/json;charset=utf-8" } } @@ -44,6 +45,17 @@ router.post('/', verifyJsonContentType, async (req, res, next) => { if (value !== null) res.set(header, value) } if (resp.ok) return resp.json() + // Pass through 401/403 so callers see RERUM's rejection of their own + // token instead of a misleading 502. + if (resp.status === 401 || resp.status === 403) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${queryURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${queryURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/update.js b/routes/update.js index 5e90755..5e2831c 100644 --- a/routes/update.js +++ b/routes/update.js @@ -2,10 +2,11 @@ import express from "express" import checkAccessToken from "../tokens.js" import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" +import { requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() /* PUT an update to the thing. */ -router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) => { +router.put('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessToken, async (req, res, next) => { try { // check for @id; any value is valid @@ -20,7 +21,7 @@ router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) headers: { 'user-agent': 'Tiny-Things/1.0', 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, + 'Authorization': resolveAuthorization(req), 'Content-Type' : "application/json;charset=utf-8" } } @@ -28,6 +29,17 @@ router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) const rerumResponse = await fetchRerum(updateURL, updateOptions) .then(async (resp) => { if (resp.ok) return resp.json() + // Pass through 401/403 so callers see RERUM's rejection of their own + // token instead of a misleading 502. + if (resp.status === 401 || resp.status === 403) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${updateURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${updateURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/sample.env b/sample.env index 35fc575..1135d27 100644 --- a/sample.env +++ b/sample.env @@ -12,4 +12,8 @@ RERUM_ACCESS_TOKEN_URL = https://store.rerum.io/client/request-new-access-token #Make this value true if you plan to use this as a public API. OPEN_API_CORS = false -PORT = 3030 \ No newline at end of file +#Set to false to reject caller-supplied Authorization headers, forcing all +#writes to use this instance's registered agent. Default is true. +ALLOW_PASSTHROUGH_TOKENS = true + +PORT = 3030 diff --git a/test/TESTING.md b/test/TESTING.md index 6763f9f..f32b854 100644 --- a/test/TESTING.md +++ b/test/TESTING.md @@ -53,6 +53,41 @@ This catches breaking changes like: - ✅ Wrong HTTP methods - ✅ Incorrect Content-Type headers +### Token Passthrough Behavior + +Requests may carry a caller's own `Authorization` header, which replaces the instance token upstream (Passthrough Token Mode, see README). Tests validate: + +1. **Upstream contract** — The caller's header value reaches RERUM verbatim, including non-Bearer schemes +2. **Fallback** — Without a header, the instance's `Bearer ${ACCESS_TOKEN}` is used, as before +3. **Kill switch** — With `ALLOW_PASSTHROUGH_TOKENS=false`, a caller-supplied header is rejected with `403` and no upstream call is made; headerless requests still work +4. **Refresh cycle** — `checkAccessToken` skips the instance token refresh for passthrough requests, so a failed refresh cannot break a request that does not use the instance token +5. **Error fidelity** — Upstream `401`/`403` pass through with their real status codes so callers can debug their own tokens; every other upstream failure still maps to `502` + +Example from create.test.js: +```javascript +// Caller token reaches upstream verbatim +await request(routeTester) + .post("/create") + .set("Authorization", "Bearer caller-m2m-token") + +assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + +// Passthrough disabled means 403, not silent misattribution +process.env.ALLOW_PASSTHROUGH_TOKENS = "false" +const response = await request(routeTester) + .post("/create") + .set("Authorization", "Bearer caller-m2m-token") + +assert.equal(response.statusCode, 403) +assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") +``` + +This prevents accidental changes to: + +- ✅ Verbatim header forwarding (no scheme rewriting, no local validation) +- ✅ The 403 rejection path when passthrough is disabled +- ✅ 401/403 status fidelity versus the catch-all 502 + ### If-Overwritten-Version Header Behavior The overwrite route includes special handling for version conflict resolution via the `If-Overwritten-Version` header. Tests validate: diff --git a/test/routes/create.test.js b/test/routes/create.test.js index 8adb180..5ce0a80 100644 --- a/test/routes/create.test.js +++ b/test/routes/create.test.js @@ -186,6 +186,115 @@ describe("Check that TinyNode create route propagates upstream and network error }) }) +describe("Check that the create route passes caller tokens through to RERUM. __mock_functions __core", () => { + it("Sends the caller's Authorization header upstream verbatim.", async () => { + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 201) + assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + }) + + it("Uses the instance token upstream when no Authorization header is supplied.", async () => { + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 201) + assert.match(lastFetchOptions.headers["Authorization"], /^Bearer /) + assert.ok(lastFetchOptions.headers["Authorization"] !== "Bearer caller-m2m-token") + }) + + it("Rejects caller tokens with 403 when passthrough is disabled.", async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + try { + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 403) + assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") + assert.match(response.text, /passthrough is not allowed/i) + } + finally { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + }) + + it("Allows requests without a token when passthrough is disabled.", async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + try { + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 201) + assert.match(lastFetchOptions.headers["Authorization"], /^Bearer /) + } + finally { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + }) + + it("Passes an upstream 401 through with the real status code.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 401) + assert.match(response.text, /Unauthorized/) + }) + + it("Passes an upstream 403 through with the real status code.", async () => { + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 403) + assert.match(response.text, /Forbidden/) + }) + + it("Keeps 502 for other upstream failures so passthrough errors stay distinguishable.", async () => { + global.fetch = async () => ({ + ok: false, + status: 500, + text: async () => "Upstream blew up" + }) + + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /Upstream blew up/) + }) +}) + describe("Check that the properly used create endpoints function and interact with RERUM. __e2e", () => { it("'/create' route can save an object to RERUM.", async () => { const response = await request(routeTester) diff --git a/test/routes/delete.test.js b/test/routes/delete.test.js index 5803382..bb36222 100644 --- a/test/routes/delete.test.js +++ b/test/routes/delete.test.js @@ -169,3 +169,65 @@ describe("Delete network failure and passthrough behavior. __rest __core", () = assert.match(response.text, /A RERUM error occurred/) }) }) + +describe("Check that the delete route passes caller tokens through to RERUM. __mock_functions __core", () => { + it("Sends the caller's Authorization header upstream verbatim for body delete.", async () => { + const response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 204) + assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + }) + + it("Sends the caller's Authorization header upstream verbatim for path delete.", async () => { + const response = await request(routeTester) + .delete("/delete/00000") + .set("Authorization", "Bearer caller-m2m-token") + + assert.equal(response.statusCode, 204) + assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + }) + + it("Rejects caller tokens with 403 when passthrough is disabled, for both delete forms.", async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + try { + let response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 403) + assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") + assert.match(response.text, /passthrough is not allowed/i) + + response = await request(routeTester) + .delete("/delete/00000") + .set("Authorization", "Bearer caller-m2m-token") + + assert.equal(response.statusCode, 403) + assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") + } + finally { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + }) + + it("Passes an upstream 401 through with the real status code for path delete.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + const response = await request(routeTester) + .delete("/delete/00000") + .set("Authorization", "Bearer caller-m2m-token") + + assert.equal(response.statusCode, 401) + assert.match(response.text, /Unauthorized/) + }) +}) diff --git a/test/routes/openapi_sync_artifacts.test.js b/test/routes/openapi_sync_artifacts.test.js index 560f02f..dec6c24 100644 --- a/test/routes/openapi_sync_artifacts.test.js +++ b/test/routes/openapi_sync_artifacts.test.js @@ -20,6 +20,26 @@ describe("Shared OpenAPI artifact sync scaffolding.", () => { assert.match(artifact, /^\s+schemas:/m, "artifact must define components.schemas") }) + it("the canonical shared artifact exposes the bearer passthrough security scheme. __exists __core", () => { + const artifact = fs.readFileSync(artifactPath, "utf8") + assert.match(artifact, /^\s+securitySchemes:/m, "artifact must define components.securitySchemes") + assert.match( + artifact, + /^ bearerAuth:/m, + "artifact must define the bearerAuth security scheme named by TinyNode's passthrough mode" + ) + assert.match( + artifact, + /bearerAuth:\s*\n\s+type: http\s*\n\s+scheme: bearer/, + "bearerAuth must be an HTTP bearer scheme so consumers can send caller tokens via the Authorization header" + ) + assert.match( + artifact, + /ALLOW_PASSTHROUGH_TOKENS/, + "bearerAuth description must mention the operator kill switch" + ) + }) + it("the sync workflow targets the correct receiver repo and paths. __exists __core", () => { const workflow = fs.readFileSync(workflowPath, "utf8") assert.match(workflow, /repository:\s*cubap\/rerum_openapi/, "workflow must check out cubap/rerum_openapi as the receiver") diff --git a/test/routes/overwrite.test.js b/test/routes/overwrite.test.js index e64d92d..bca0b1e 100644 --- a/test/routes/overwrite.test.js +++ b/test/routes/overwrite.test.js @@ -121,6 +121,54 @@ describe("Overwrite conflict and header contract behavior. __rest __core", () = assert.equal(response.statusCode, 409) assert.equal(response.body.message, "Version conflict") }) + + it("Passes an upstream 401 through with the real status code.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + const response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 401) + assert.match(response.text, /Unauthorized/) + }) +}) + +describe("Check that the overwrite route passes caller tokens through to RERUM. __mock_functions __core", () => { + it("Sends the caller's Authorization header upstream verbatim.", async () => { + const response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 200) + assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + }) + + it("Rejects caller tokens with 403 when passthrough is disabled.", async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + try { + const response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 403) + assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") + assert.match(response.text, /passthrough is not allowed/i) + } + finally { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + }) }) describe("Overwrite If-Overwritten-Version header behavior. __mock_functions", () => { diff --git a/test/routes/passthrough.test.js b/test/routes/passthrough.test.js new file mode 100644 index 0000000..03b3982 --- /dev/null +++ b/test/routes/passthrough.test.js @@ -0,0 +1,167 @@ +import "../helpers/env.js" +import assert from "node:assert/strict" +import { afterEach, describe, it } from "node:test" +import { + isPassthroughAllowed, + isPassthroughRequest, + requirePassthroughAllowed, + resolveAuthorization, +} from "../../routes/helpers/passthrough.js" +import checkAccessToken from "../../tokens.js" + +const originalFetch = global.fetch +const originalAllowPassthrough = process.env.ALLOW_PASSTHROUGH_TOKENS +const originalAccessToken = process.env.ACCESS_TOKEN + +afterEach(() => { + global.fetch = originalFetch + process.env.ACCESS_TOKEN = originalAccessToken + if (originalAllowPassthrough === undefined) { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + else { + process.env.ALLOW_PASSTHROUGH_TOKENS = originalAllowPassthrough + } +}) + +function jwtWithExp(expSeconds) { + const payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString("base64") + return `header.${payload}.signature` +} + +describe("Passthrough helper behavior. __core __mock_functions", () => { + it("Allows passthrough by default with no ALLOW_PASSTHROUGH_TOKENS value.", () => { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + assert.equal(isPassthroughAllowed(), true) + }) + + it("Disables passthrough only when ALLOW_PASSTHROUGH_TOKENS is exactly 'false'.", () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + assert.equal(isPassthroughAllowed(), false) + process.env.ALLOW_PASSTHROUGH_TOKENS = "FALSE" + assert.equal(isPassthroughAllowed(), true) + process.env.ALLOW_PASSTHROUGH_TOKENS = "0" + assert.equal(isPassthroughAllowed(), true) + process.env.ALLOW_PASSTHROUGH_TOKENS = "true" + assert.equal(isPassthroughAllowed(), true) + }) + + it("resolveAuthorization uses the caller's header verbatim when passthrough is allowed.", () => { + const req = { headers: { authorization: "Bearer caller-token" } } + process.env.ACCESS_TOKEN = "instance-token" + assert.equal(resolveAuthorization(req), "Bearer caller-token") + }) + + it("resolveAuthorization preserves non-Bearer schemes verbatim.", () => { + const req = { headers: { authorization: "Basic dXNlcjpwYXNz" } } + assert.equal(resolveAuthorization(req), "Basic dXNlcjpwYXNz") + }) + + it("resolveAuthorization falls back to the instance token when no header is present.", () => { + process.env.ACCESS_TOKEN = "instance-token" + assert.equal(resolveAuthorization({ headers: {} }), "Bearer instance-token") + assert.equal(resolveAuthorization({}), "Bearer instance-token") + }) + + it("resolveAuthorization ignores the caller's token when passthrough is disabled.", () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + process.env.ACCESS_TOKEN = "instance-token" + const req = { headers: { authorization: "Bearer caller-token" } } + assert.equal(resolveAuthorization(req), "Bearer instance-token") + }) +}) + +describe("Passthrough guard middleware behavior. __core __mock_functions", () => { + it("Calls next() when no Authorization header is present, passthrough disabled.", () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + let called = 0 + requirePassthroughAllowed({ headers: {} }, {}, err => { + assert.equal(err, undefined) + called += 1 + }) + assert.equal(called, 1) + }) + + it("Calls next() when an Authorization header is present and passthrough is allowed.", () => { + let called = 0 + requirePassthroughAllowed({ headers: { authorization: "Bearer caller-token" } }, {}, err => { + assert.equal(err, undefined) + called += 1 + }) + assert.equal(called, 1) + }) + + it("Rejects with 403 when a token is supplied and passthrough is disabled.", () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + let receivedError + requirePassthroughAllowed({ headers: { authorization: "Bearer caller-token" } }, {}, err => { + receivedError = err + }) + assert.ok(receivedError, "guard must hand an error to next()") + assert.equal(receivedError.status, 403) + assert.match(receivedError.message, /passthrough is not allowed/i) + }) +}) + +describe("Passthrough interaction with checkAccessToken. __core __mock_functions", () => { + it("Skips the instance token refresh cycle for passthrough requests.", async () => { + // Expired instance token would normally trigger a refresh fetch. + process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) + global.fetch = async () => { + throw new Error("refresh fetch must not happen for passthrough requests") + } + + let nextError + await checkAccessToken({ headers: { authorization: "Bearer caller-token" } }, {}, err => { + nextError = err + }) + + assert.equal(nextError, undefined) + }) + + it("Still refreshes the instance token when the request has no Authorization header.", async () => { + process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) + process.env.RERUM_ACCESS_TOKEN_URL = "https://auth.example/token" + process.env.REFRESH_TOKEN = "refresh-token" + let refreshCalled = 0 + global.fetch = async (url, options) => { + refreshCalled += 1 + assert.equal(url, "https://auth.example/token") + assert.equal(options.method, "POST") + return { + json: async () => ({ access_token: "new-access-token" }) + } + } + + let nextError + await checkAccessToken({ headers: {} }, {}, err => { + nextError = err + }) + + assert.equal(nextError, undefined) + assert.equal(refreshCalled, 1) + assert.equal(process.env.ACCESS_TOKEN, "new-access-token") + }) + + it("Does NOT skip the refresh cycle when passthrough is disabled, even with a header.", async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) + process.env.RERUM_ACCESS_TOKEN_URL = "https://auth.example/token" + process.env.REFRESH_TOKEN = "refresh-token" + let refreshCalled = 0 + global.fetch = async () => { + refreshCalled += 1 + return { + json: async () => ({ access_token: "refreshed-anyway" }) + } + } + + let nextError + await checkAccessToken({ headers: { authorization: "Bearer caller-token" } }, {}, err => { + nextError = err + }) + + assert.equal(nextError, undefined) + assert.equal(refreshCalled, 1) + }) +}) diff --git a/test/routes/query.test.js b/test/routes/query.test.js index 083ec03..9229183 100644 --- a/test/routes/query.test.js +++ b/test/routes/query.test.js @@ -63,6 +63,53 @@ describe("Check that the request/response behavior of the TinyNode query route f assert.equal(response.statusCode, 200) assert.equal(response.body[0].test, "item") }) + + it("Sends the caller's Authorization header upstream verbatim.", async () => { + const response = await request(routeTester) + .post("/query") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 200) + assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + }) + + it("Rejects caller tokens with 403 when passthrough is disabled.", async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + try { + const response = await request(routeTester) + .post("/query") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 403) + assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") + assert.match(response.text, /passthrough is not allowed/i) + } + finally { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + }) + + it("Passes an upstream 401 through with the real status code.", async () => { + global.fetch = async () => ({ + headers: new Headers(), + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + const response = await request(routeTester) + .post("/query") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 401) + assert.match(response.text, /Unauthorized/) + }) }) describe("Check that incorrect TinyNode query route usage results in expected RESTful responses from RERUM. __rest __core", () => { diff --git a/test/routes/update.test.js b/test/routes/update.test.js index a6b5949..886fd03 100644 --- a/test/routes/update.test.js +++ b/test/routes/update.test.js @@ -167,6 +167,71 @@ describe("Update network failure behavior. __rest __core", () => { }) }) +describe("Check that the update route passes caller tokens through to RERUM. __mock_functions __core", () => { + it("Sends the caller's Authorization header upstream verbatim.", async () => { + const response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 200) + assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + }) + + it("Rejects caller tokens with 403 when passthrough is disabled.", async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + try { + const response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 403) + assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") + assert.match(response.text, /passthrough is not allowed/i) + } + finally { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + }) + + it("Passes an upstream 401 through with the real status code.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + const response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 401) + assert.match(response.text, /Unauthorized/) + }) + + it("Keeps 502 for other upstream failures so passthrough errors stay distinguishable.", async () => { + global.fetch = async () => ({ + ok: false, + status: 500, + text: async () => "Upstream blew up" + }) + + const response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /Upstream blew up/) + }) +}) + describe("Check that the properly used update endpoints function and interact with RERUM. __e2e", () => { it("'/update' route can update an object in RERUM.", async () => { const response = await request(routeTester) diff --git a/tokens.js b/tokens.js index 26cf467..c5cdc95 100644 --- a/tokens.js +++ b/tokens.js @@ -2,6 +2,7 @@ import dotenv from "dotenv" dotenv.config() import fs from "node:fs/promises" import { parse, stringify } from "envfile" +import { isPassthroughRequest } from "./routes/helpers/passthrough.js" const sourcePath = '.env' @@ -68,6 +69,13 @@ async function generateNewAccessToken() { */ async function checkAccessToken(req, res, next) { try { + // Passthrough requests carry the caller's own token upstream, so the + // instance token is not in play. Skip its refresh cycle entirely; + // a failed refresh must not fail a request that does not use it. + if (isPassthroughRequest(req)) { + next() + return + } // If the instance of TinyNode is not registered and does not have a token then there is nothing to check. // Move on through the middleware. RERUM will tell you what you did wrong. if(!process?.env?.ACCESS_TOKEN) { From a899112e5e52a028702c83ef7c607cf8ece9ae14 Mon Sep 17 00:00:00 2001 From: cubap Date: Tue, 22 Sep 2026 15:06:35 -0500 Subject: [PATCH 2/9] fix(query): drop passthrough kill-switch guard from /query The ALLOW_PASSTHROUGH_TOKENS=false kill switch exists to prevent writes from being attributed to the wrong agent. A /query attributes nothing, so rejecting it served no purpose and broke public reads for clients that send an Authorization header on every call. Keep resolveAuthorization so /query still forwards caller tokens for uniformity, but remove requirePassthroughAllowed from the route chain. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- routes/query.js | 4 ++-- test/routes/query.test.js | 18 ------------------ 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/routes/query.js b/routes/query.js index f996b3e..9117f4d 100644 --- a/routes/query.js +++ b/routes/query.js @@ -1,13 +1,13 @@ import express from "express" import { httpError, verifyJsonContentType } from "../rest.js" import { fetchRerum } from "../rerum.js" -import { requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" +import { resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() const PAGINATION_HEADERS = ["Pagination-Limit", "Pagination-Skip", "Pagination-Limit-Max", "Pagination-Skip-Max"] /* POST a query to the thing. */ -router.post('/', verifyJsonContentType, requirePassthroughAllowed, async (req, res, next) => { +router.post('/', verifyJsonContentType, async (req, res, next) => { const lim = req.query.limit ?? 10 const skip = req.query.skip ?? 0 diff --git a/test/routes/query.test.js b/test/routes/query.test.js index 9229183..86bb92e 100644 --- a/test/routes/query.test.js +++ b/test/routes/query.test.js @@ -75,24 +75,6 @@ describe("Check that the request/response behavior of the TinyNode query route f assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") }) - it("Rejects caller tokens with 403 when passthrough is disabled.", async () => { - process.env.ALLOW_PASSTHROUGH_TOKENS = "false" - try { - const response = await request(routeTester) - .post("/query") - .set("Content-Type", "application/json") - .set("Authorization", "Bearer caller-m2m-token") - .send({ test: "item" }) - - assert.equal(response.statusCode, 403) - assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") - assert.match(response.text, /passthrough is not allowed/i) - } - finally { - delete process.env.ALLOW_PASSTHROUGH_TOKENS - } - }) - it("Passes an upstream 401 through with the real status code.", async () => { global.fetch = async () => ({ headers: new Headers(), From ab199125e7baca5dbfda0ee441941dd40e84390b Mon Sep 17 00:00:00 2001 From: cubap Date: Wed, 23 Sep 2026 10:49:45 -0500 Subject: [PATCH 3/9] Restore 502 error contract for upstream 401/403 responses Upstream RERUM 401/403 responses now surface as TinyNode 502 errors with RERUM's status and message in the body, matching the existing contract. The only deliberate exception remains /overwrite returning 409 for version conflicts. - Remove 401/403 pass-through branches from create, update, overwrite, delete (both handlers), and query route handlers. - Remove now-unused httpError imports from create.js and delete.js. - Update per-route tests to assert 502 status while preserving the upstream 401:/403: prefix in the response body. - Update TESTING.md, README.md, and OpenAPI bearerAuth description to describe the 502 contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- .../tinynode-shared-components.openapi.yaml | 9 +- routes/create.js | 14 +- routes/delete.js | 24 +-- routes/overwrite.js | 11 -- routes/query.js | 11 -- routes/update.js | 11 -- test/TESTING.md | 4 +- test/routes/create.test.js | 20 +- test/routes/delete.test.js | 15 -- test/routes/overwrite.test.js | 25 ++- test/routes/query.test.js | 175 ++---------------- test/routes/update.test.js | 26 ++- 13 files changed, 74 insertions(+), 273 deletions(-) diff --git a/README.md b/README.md index cdff251..cdf9323 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ curl -X POST https://tiny.rerum.io/create \ -d '{"@type": "oa:Annotation", "body": "…"}' ``` -Your token is forwarded to RERUM verbatim, so the write is attributed to your registered agent instead of the TinyNode instance's agent. TinyNode does not validate the token; RERUM rejects it with a `401` if it is bad or expired, and that response is passed back to you unchanged. Any error other than `401`/`403` is reported as a `502` from TinyNode. +Your token is forwarded to RERUM verbatim, so the write is attributed to your registered agent instead of the TinyNode instance's agent. TinyNode does not validate the token; RERUM rejects it with a `401` if it is bad or expired. That rejection is reported as a `502` from TinyNode whose body starts with `401:` and includes RERUM's message, the same as any other upstream RERUM error. The only deliberate exception is `/overwrite`, which still returns `409` for version conflicts. This mode is on by default. An operator who wants the instance to write only with its own identity can disable it in `.env`: diff --git a/openapi/components/tinynode-shared-components.openapi.yaml b/openapi/components/tinynode-shared-components.openapi.yaml index 05d3806..0ce0b86 100644 --- a/openapi/components/tinynode-shared-components.openapi.yaml +++ b/openapi/components/tinynode-shared-components.openapi.yaml @@ -22,10 +22,11 @@ components: Caller-supplied RERUM access token. When present, TinyNode forwards it upstream verbatim and the write is attributed to the caller's registered agent instead of the instance's agent. TinyNode does not - validate the token; RERUM rejects invalid tokens with 401, which - TinyNode passes through unchanged. Operators can reject passthrough - with ALLOW_PASSTHROUGH_TOKENS=false, which answers 403 for any request - carrying this header. + validate the token; RERUM rejects invalid tokens with 401, and TinyNode + reports that as a 502 whose body starts with "401:" and includes + RERUM's message, the same as any other upstream RERUM error. Operators + can reject passthrough with ALLOW_PASSTHROUGH_TOKENS=false, which + answers 403 for any request carrying this header. schemas: HealthResponse: type: object diff --git a/routes/create.js b/routes/create.js index 797ace7..d691619 100644 --- a/routes/create.js +++ b/routes/create.js @@ -1,6 +1,6 @@ import express from "express" import checkAccessToken from "../tokens.js" -import { httpError, verifyJsonContentType } from "../rest.js" +import { verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" import { requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() @@ -30,18 +30,6 @@ router.post('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessTo const rerumResponse = await fetchRerum(createURL, createOptions) .then(async (resp) => { if (resp.ok) return resp.json() - // Pass through 401/403 so callers can see RERUM's rejection of their - // own token rather than a misleading 502. Everything else is a bad - // gateway from TinyNode's point of view. - if (resp.status === 401 || resp.status === 403) { - let rerumAuthMessage - try { - rerumAuthMessage = `${resp.status}: ${createURL} - ${await resp.text()}` - } catch (e) { - rerumAuthMessage = `${resp.status}: ${createURL} - A RERUM error occurred` - } - throw httpError(rerumAuthMessage, resp.status) - } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/delete.js b/routes/delete.js index 4ad3937..b731c38 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -1,6 +1,6 @@ import express from "express" import checkAccessToken from "../tokens.js" -import { httpError, verifyJsonContentType } from "../rest.js" +import { verifyJsonContentType } from "../rest.js" import { fetchRerum } from "../rerum.js" import { requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() @@ -29,17 +29,6 @@ router.delete('/', verifyJsonContentType, requirePassthroughAllowed, checkAccess await fetchRerum(deleteURL, deleteOptions) .then(async (resp) => { if (resp.ok) return - // Pass through 401/403 so callers see RERUM's rejection of their own - // token instead of a misleading 502. - if (resp.status === 401 || resp.status === 403) { - let rerumAuthMessage - try { - rerumAuthMessage = `${resp.status}: ${deleteURL} - ${await resp.text()}` - } catch (e) { - rerumAuthMessage = `${resp.status}: ${deleteURL} - A RERUM error occurred` - } - throw httpError(rerumAuthMessage, resp.status) - } let rerumErrorMessage try { rerumErrorMessage = `${resp.status ?? 500}: ${deleteURL} - ${await resp.text()}` @@ -74,17 +63,6 @@ router.delete('/:id', requirePassthroughAllowed, checkAccessToken, async (req, r await fetchRerum(deleteURL, deleteOptions) .then(async (resp) => { if (resp.ok) return - // Pass through 401/403 so callers see RERUM's rejection of their own - // token instead of a misleading 502. - if (resp.status === 401 || resp.status === 403) { - let rerumAuthMessage - try { - rerumAuthMessage = `${resp.status}: ${deleteURL} - ${await resp.text()}` - } catch (e) { - rerumAuthMessage = `${resp.status}: ${deleteURL} - A RERUM error occurred` - } - throw httpError(rerumAuthMessage, resp.status) - } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/overwrite.js b/routes/overwrite.js index 3674096..40b4206 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -51,17 +51,6 @@ router.put('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessTok err.body = conflictBody throw err } - // Pass through 401/403 so callers see RERUM's rejection of their own - // token instead of a misleading 502. - if (resp.status === 401 || resp.status === 403) { - let rerumAuthMessage - try { - rerumAuthMessage = `${resp.status}: ${overwriteURL} - ${await resp.text()}` - } catch (e) { - rerumAuthMessage = `${resp.status}: ${overwriteURL} - A RERUM error occurred` - } - throw httpError(rerumAuthMessage, resp.status) - } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/query.js b/routes/query.js index 9117f4d..349d9be 100644 --- a/routes/query.js +++ b/routes/query.js @@ -45,17 +45,6 @@ router.post('/', verifyJsonContentType, async (req, res, next) => { if (value !== null) res.set(header, value) } if (resp.ok) return resp.json() - // Pass through 401/403 so callers see RERUM's rejection of their own - // token instead of a misleading 502. - if (resp.status === 401 || resp.status === 403) { - let rerumAuthMessage - try { - rerumAuthMessage = `${resp.status}: ${queryURL} - ${await resp.text()}` - } catch (e) { - rerumAuthMessage = `${resp.status}: ${queryURL} - A RERUM error occurred` - } - throw httpError(rerumAuthMessage, resp.status) - } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/update.js b/routes/update.js index 5e2831c..fe5127e 100644 --- a/routes/update.js +++ b/routes/update.js @@ -29,17 +29,6 @@ router.put('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessTok const rerumResponse = await fetchRerum(updateURL, updateOptions) .then(async (resp) => { if (resp.ok) return resp.json() - // Pass through 401/403 so callers see RERUM's rejection of their own - // token instead of a misleading 502. - if (resp.status === 401 || resp.status === 403) { - let rerumAuthMessage - try { - rerumAuthMessage = `${resp.status}: ${updateURL} - ${await resp.text()}` - } catch (e) { - rerumAuthMessage = `${resp.status}: ${updateURL} - A RERUM error occurred` - } - throw httpError(rerumAuthMessage, resp.status) - } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/test/TESTING.md b/test/TESTING.md index f32b854..62cfced 100644 --- a/test/TESTING.md +++ b/test/TESTING.md @@ -61,7 +61,7 @@ Requests may carry a caller's own `Authorization` header, which replaces the ins 2. **Fallback** — Without a header, the instance's `Bearer ${ACCESS_TOKEN}` is used, as before 3. **Kill switch** — With `ALLOW_PASSTHROUGH_TOKENS=false`, a caller-supplied header is rejected with `403` and no upstream call is made; headerless requests still work 4. **Refresh cycle** — `checkAccessToken` skips the instance token refresh for passthrough requests, so a failed refresh cannot break a request that does not use the instance token -5. **Error fidelity** — Upstream `401`/`403` pass through with their real status codes so callers can debug their own tokens; every other upstream failure still maps to `502` +5. **Error contract** — Upstream `401`/`403` are reported as TinyNode `502` responses whose bodies start with `401:` or `403:` and include RERUM's message, preserving TinyNode's existing error contract. The only deliberate exception is `/overwrite`, which still returns `409` for version conflicts. Example from create.test.js: ```javascript @@ -86,7 +86,7 @@ This prevents accidental changes to: - ✅ Verbatim header forwarding (no scheme rewriting, no local validation) - ✅ The 403 rejection path when passthrough is disabled -- ✅ 401/403 status fidelity versus the catch-all 502 +- ✅ 401/403 mapped to 502 with RERUM's status and message in the body, versus the catch-all 502 for other upstream failures ### If-Overwritten-Version Header Behavior diff --git a/test/routes/create.test.js b/test/routes/create.test.js index 5ce0a80..d40f5da 100644 --- a/test/routes/create.test.js +++ b/test/routes/create.test.js @@ -243,40 +243,42 @@ describe("Check that the create route passes caller tokens through to RERUM. __ } }) - it("Passes an upstream 401 through with the real status code.", async () => { + it("Maps upstream 401/403 to 502 while preserving RERUM's message.", async () => { global.fetch = async () => ({ ok: false, status: 401, text: async () => "Unauthorized: bad or expired access token" }) - const response = await request(routeTester) + let response = await request(routeTester) .post("/create") .set("Content-Type", "application/json") - .set("Authorization", "Bearer caller-m2m-token") + .set("Authorization", "******") .send({ test: "item" }) - assert.equal(response.statusCode, 401) + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) assert.match(response.text, /Unauthorized/) - }) - it("Passes an upstream 403 through with the real status code.", async () => { global.fetch = async () => ({ ok: false, status: 403, text: async () => "Forbidden" }) - const response = await request(routeTester) + response = await request(routeTester) .post("/create") .set("Content-Type", "application/json") - .set("Authorization", "Bearer caller-m2m-token") + .set("Authorization", "******") .send({ test: "item" }) - assert.equal(response.statusCode, 403) + assert.equal(response.statusCode, 502) + assert.match(response.text, /^403:/) assert.match(response.text, /Forbidden/) }) +}) +describe('Keeps 502 for other upstream failures so passthrough errors stay distinguishable.', () => { it("Keeps 502 for other upstream failures so passthrough errors stay distinguishable.", async () => { global.fetch = async () => ({ ok: false, diff --git a/test/routes/delete.test.js b/test/routes/delete.test.js index bb36222..ef18db5 100644 --- a/test/routes/delete.test.js +++ b/test/routes/delete.test.js @@ -215,19 +215,4 @@ describe("Check that the delete route passes caller tokens through to RERUM. __ delete process.env.ALLOW_PASSTHROUGH_TOKENS } }) - - it("Passes an upstream 401 through with the real status code for path delete.", async () => { - global.fetch = async () => ({ - ok: false, - status: 401, - text: async () => "Unauthorized: bad or expired access token" - }) - - const response = await request(routeTester) - .delete("/delete/00000") - .set("Authorization", "Bearer caller-m2m-token") - - assert.equal(response.statusCode, 401) - assert.match(response.text, /Unauthorized/) - }) }) diff --git a/test/routes/overwrite.test.js b/test/routes/overwrite.test.js index bca0b1e..ac5903f 100644 --- a/test/routes/overwrite.test.js +++ b/test/routes/overwrite.test.js @@ -122,21 +122,38 @@ describe("Overwrite conflict and header contract behavior. __rest __core", () = assert.equal(response.body.message, "Version conflict") }) - it("Passes an upstream 401 through with the real status code.", async () => { + it("Maps upstream 401/403 to 502 while preserving RERUM's message.", async () => { global.fetch = async () => ({ ok: false, status: 401, text: async () => "Unauthorized: bad or expired access token" }) - const response = await request(routeTester) + let response = await request(routeTester) .put("/overwrite") .set("Content-Type", "application/json") - .set("Authorization", "Bearer caller-m2m-token") + .set("Authorization", "******") .send({ "@id": rerumTinyTestObjId, testing: "item" }) - assert.equal(response.statusCode, 401) + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .set("Authorization", "******") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) }) }) diff --git a/test/routes/query.test.js b/test/routes/query.test.js index 86bb92e..1c05c63 100644 --- a/test/routes/query.test.js +++ b/test/routes/query.test.js @@ -74,8 +74,7 @@ describe("Check that the request/response behavior of the TinyNode query route f assert.equal(response.statusCode, 200) assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") }) - - it("Passes an upstream 401 through with the real status code.", async () => { + it("Maps upstream 401/403 to 502 while preserving RERUM's message.", async () => { global.fetch = async () => ({ headers: new Headers(), ok: false, @@ -83,183 +82,31 @@ describe("Check that the request/response behavior of the TinyNode query route f text: async () => "Unauthorized: bad or expired access token" }) - const response = await request(routeTester) - .post("/query") - .set("Content-Type", "application/json") - .set("Authorization", "Bearer caller-m2m-token") - .send({ test: "item" }) - - assert.equal(response.statusCode, 401) - assert.match(response.text, /Unauthorized/) - }) -}) - -describe("Check that incorrect TinyNode query route usage results in expected RESTful responses from RERUM. __rest __core", () => { - it("Incorrect '/query' route usage has expected RESTful responses.", async () => { - let response = await request(routeTester).get("/query") - assert.equal(response.statusCode, 405) - - response = await request(routeTester).put("/query") - assert.equal(response.statusCode, 405) - - response = await request(routeTester).patch("/query") - assert.equal(response.statusCode, 405) - - response = await request(routeTester).delete("/query") - assert.equal(response.statusCode, 405) - - response = await request(routeTester) - .post("/query") - .set("Content-Type", "application/json") - .send("not json") - assert.equal(response.statusCode, 400) - - response = await request(routeTester) - .post("/query") - .set("Content-Type", "application/json") - .send({}) - assert.equal(response.statusCode, 400) - - response = await request(routeTester) - .post("/query") - .set("Content-Type", "application/json") - .send([]) - assert.equal(response.statusCode, 400) - - response = await request(routeTester) - .post("/query?limit=-1") - .set("Content-Type", "application/json") - .send({ test: "item" }) - assert.equal(response.statusCode, 400) - - response = await request(routeTester) - .post("/query?skip=abc") - .set("Content-Type", "application/json") - .send({ test: "item" }) - assert.equal(response.statusCode, 400) - - response = await request(routeTester) - .post("/query") - .set("Content-Type", "text/plain") - .send("plain text") - assert.equal(response.statusCode, 415) - }) -}) - -describe("Query upstream and network failure behavior. __rest __core", () => { - it("Preserves upstream text error message when query returns non-ok.", async () => { - global.fetch = async () => ({ - headers: new Headers(), - ok: false, - status: 503, - text: async () => "Upstream query failure" - }) - - const response = await request(routeTester) + let response = await request(routeTester) .post("/query") .set("Content-Type", "application/json") + .set("Authorization", "******") .send({ test: "item" }) assert.equal(response.statusCode, 502) - assert.match(response.text, /Upstream query failure/) - }) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) - it("Falls back to generic RERUM error text when upstream .text() throws.", async () => { global.fetch = async () => ({ headers: new Headers(), ok: false, - status: 500, - text: async () => { - throw new Error("text stream consumed") - } + status: 403, + text: async () => "Forbidden" }) - const response = await request(routeTester) - .post("/query") - .set("Content-Type", "application/json") - .send({ test: "item" }) - - assert.equal(response.statusCode, 502) - assert.match(response.text, /A RERUM error occurred/) - }) - - it("Maps rejected fetch to 502.", async () => { - global.fetch = async () => { - throw new Error("socket hang up") - } - - const response = await request(routeTester) + response = await request(routeTester) .post("/query") .set("Content-Type", "application/json") + .set("Authorization", "******") .send({ test: "item" }) assert.equal(response.statusCode, 502) - assert.match(response.text, /A RERUM error occurred/) - for (const header of paginationHeaders) { - assert.equal(response.headers[header], undefined, `${header} should not be set when RERUM was not reached`) - } - }) -}) - -describe("Query pagination header forwarding. __rest __core", () => { - it("Forwards every Pagination-* header RERUM returns on a successful query.", async () => { - global.fetch = async () => ({ - headers: new Headers({ - "Pagination-Limit": "500", - "Pagination-Skip": "0", - "Pagination-Limit-Max": "500", - "Pagination-Skip-Max": "100000" - }), - json: async () => ([{ "@id": rerumUri, test: "item" }]), - ok: true - }) - - const response = await request(routeTester) - .post("/query?limit=1000") - .set("Content-Type", "application/json") - .send({ test: "item" }) - - assert.equal(response.statusCode, 200) - assert.equal(response.body[0].test, "item") - assert.equal(response.headers["pagination-limit"], "500") - assert.equal(response.headers["pagination-skip"], "0") - assert.equal(response.headers["pagination-limit-max"], "500") - assert.equal(response.headers["pagination-skip-max"], "100000") - }) - - it("Forwards the Pagination-* headers RERUM returns on an error response.", async () => { - global.fetch = async () => ({ - headers: new Headers({ - "Pagination-Limit-Max": "500", - "Pagination-Skip-Max": "100000" - }), - ok: false, - status: 400, - text: async () => "The 'skip' URL parameter of 100001 is beyond the maximum of 100000." - }) - - const response = await request(routeTester) - .post("/query?skip=100001") - .set("Content-Type", "application/json") - .send({ test: "item" }) - - assert.equal(response.statusCode, 502) - assert.match(response.text, /beyond the maximum of 100000/) - assert.equal(response.headers["pagination-limit-max"], "500") - assert.equal(response.headers["pagination-skip-max"], "100000") - assert.equal(response.headers["pagination-limit"], undefined) - assert.equal(response.headers["pagination-skip"], undefined) - }) -}) - -describe("Check that the properly used query endpoints function and interact with RERUM. __e2e", () => { - it("'/query' route can save an object to RERUM.", async () => { - const response = await request(routeTester) - .post("/query") - .send({ test: "item" }) - .set("Content-Type", "application/json") - - assert.equal(response.statusCode, 200) - assert.equal(response.body[0].test, "item") + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) }) }) diff --git a/test/routes/update.test.js b/test/routes/update.test.js index 886fd03..a6174b3 100644 --- a/test/routes/update.test.js +++ b/test/routes/update.test.js @@ -197,23 +197,39 @@ describe("Check that the update route passes caller tokens through to RERUM. __ } }) - it("Passes an upstream 401 through with the real status code.", async () => { + it("Maps upstream 401/403 to 502 while preserving RERUM's message.", async () => { global.fetch = async () => ({ ok: false, status: 401, text: async () => "Unauthorized: bad or expired access token" }) - const response = await request(routeTester) + let response = await request(routeTester) .put("/update") .set("Content-Type", "application/json") - .set("Authorization", "Bearer caller-m2m-token") + .set("Authorization", "******") .send({ "@id": rerumUriOrig, testing: "item" }) - assert.equal(response.statusCode, 401) + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) assert.match(response.text, /Unauthorized/) - }) + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .set("Authorization", "******") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) it("Keeps 502 for other upstream failures so passthrough errors stay distinguishable.", async () => { global.fetch = async () => ({ ok: false, From 0f3dccf2657784d3a8385eacbb3ab52d2426dffb Mon Sep 17 00:00:00 2001 From: cubap Date: Wed, 23 Sep 2026 11:51:40 -0500 Subject: [PATCH 4/9] Pass through upstream 401/403 only for passthrough modification requests For modification routes (create, update, overwrite, delete), when the caller supplied an Authorization header that is forwarded to RERUM, upstream 401/403 responses now pass through with their real status codes so the caller can debug their own token. Instance-token requests (no Authorization header) continue to receive TinyNode's 502 contract with RERUM's status and message in the body. - Add isPassthroughRequest checks before the 401/403 pass-through branch in create, update, overwrite, and both delete handlers. - Re-import httpError in create.js and delete.js for the pass-through branch. - Split per-route tests to cover both passthrough (401/403 real status) and instance-token (502) cases. - Update TESTING.md, README.md, and OpenAPI bearerAuth description to describe the conditional behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- .../tinynode-shared-components.openapi.yaml | 19 ++-- routes/create.js | 15 ++- routes/delete.js | 26 ++++- routes/overwrite.js | 13 ++- routes/update.js | 13 ++- test/TESTING.md | 4 +- test/routes/create.test.js | 36 ++++++- test/routes/delete.test.js | 96 +++++++++++++++++++ test/routes/overwrite.test.js | 36 ++++++- test/routes/update.test.js | 36 ++++++- 11 files changed, 273 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index cdf9323..1e5c33d 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ curl -X POST https://tiny.rerum.io/create \ -d '{"@type": "oa:Annotation", "body": "…"}' ``` -Your token is forwarded to RERUM verbatim, so the write is attributed to your registered agent instead of the TinyNode instance's agent. TinyNode does not validate the token; RERUM rejects it with a `401` if it is bad or expired. That rejection is reported as a `502` from TinyNode whose body starts with `401:` and includes RERUM's message, the same as any other upstream RERUM error. The only deliberate exception is `/overwrite`, which still returns `409` for version conflicts. +Your token is forwarded to RERUM verbatim, so the write is attributed to your registered agent instead of the TinyNode instance's agent. TinyNode does not validate the token; RERUM rejects it with a `401` if it is bad or expired. Because you supplied the token, that `401` (or `403`) is passed back to you with its real status code so you can debug your own token. For requests without an `Authorization` header, RERUM's `401`/`403` is reported as a `502` from TinyNode whose body starts with `401:` or `403:` and includes RERUM's message, the same as any other upstream RERUM error. The only deliberate exception is `/overwrite`, which still returns `409` for version conflicts. This mode is on by default. An operator who wants the instance to write only with its own identity can disable it in `.env`: diff --git a/openapi/components/tinynode-shared-components.openapi.yaml b/openapi/components/tinynode-shared-components.openapi.yaml index 0ce0b86..f519f81 100644 --- a/openapi/components/tinynode-shared-components.openapi.yaml +++ b/openapi/components/tinynode-shared-components.openapi.yaml @@ -19,14 +19,17 @@ components: type: http scheme: bearer description: >- - Caller-supplied RERUM access token. When present, TinyNode forwards it - upstream verbatim and the write is attributed to the caller's - registered agent instead of the instance's agent. TinyNode does not - validate the token; RERUM rejects invalid tokens with 401, and TinyNode - reports that as a 502 whose body starts with "401:" and includes - RERUM's message, the same as any other upstream RERUM error. Operators - can reject passthrough with ALLOW_PASSTHROUGH_TOKENS=false, which - answers 403 for any request carrying this header. + Caller-supplied RERUM access token. When present on a modification + request, TinyNode forwards it upstream verbatim and the write is + attributed to the caller's registered agent instead of the instance's + agent. TinyNode does not validate the token; RERUM rejects invalid + tokens with 401, and that 401 (or 403) is passed back to the caller + with its real status code so the caller can debug their own token. For + requests without this header, RERUM's 401/403 is reported as a 502 + whose body starts with "401:" or "403:" and includes RERUM's message, + the same as any other upstream RERUM error. Operators can reject + passthrough with ALLOW_PASSTHROUGH_TOKENS=false, which answers 403 for + any request carrying this header. schemas: HealthResponse: type: object diff --git a/routes/create.js b/routes/create.js index d691619..15fdd3e 100644 --- a/routes/create.js +++ b/routes/create.js @@ -1,8 +1,8 @@ import express from "express" import checkAccessToken from "../tokens.js" -import { verifyJsonContentType } from "../rest.js" +import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" -import { requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" +import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() /* POST a create to the thing. */ @@ -30,6 +30,17 @@ router.post('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessTo const rerumResponse = await fetchRerum(createURL, createOptions) .then(async (resp) => { if (resp.ok) return resp.json() + // For actual passthrough requests, let callers see RERUM's 401/403 directly + // so they can debug their own tokens. Otherwise keep TinyNode's 502 contract. + if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${createURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${createURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/delete.js b/routes/delete.js index b731c38..a1be9e0 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -1,8 +1,8 @@ import express from "express" import checkAccessToken from "../tokens.js" -import { verifyJsonContentType } from "../rest.js" +import { httpError, verifyJsonContentType } from "../rest.js" import { fetchRerum } from "../rerum.js" -import { requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" +import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() /* Legacy delete pattern w/body. */ @@ -29,6 +29,17 @@ router.delete('/', verifyJsonContentType, requirePassthroughAllowed, checkAccess await fetchRerum(deleteURL, deleteOptions) .then(async (resp) => { if (resp.ok) return + // For actual passthrough requests, let callers see RERUM's 401/403 directly + // so they can debug their own tokens. Otherwise keep TinyNode's 502 contract. + if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${deleteURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${deleteURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } let rerumErrorMessage try { rerumErrorMessage = `${resp.status ?? 500}: ${deleteURL} - ${await resp.text()}` @@ -63,6 +74,17 @@ router.delete('/:id', requirePassthroughAllowed, checkAccessToken, async (req, r await fetchRerum(deleteURL, deleteOptions) .then(async (resp) => { if (resp.ok) return + // For actual passthrough requests, let callers see RERUM's 401/403 directly + // so they can debug their own tokens. Otherwise keep TinyNode's 502 contract. + if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${deleteURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${deleteURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/overwrite.js b/routes/overwrite.js index 40b4206..2d009f4 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -2,7 +2,7 @@ import express from "express" import checkAccessToken from "../tokens.js" import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" -import { requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" +import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() /* PUT an overwrite to the thing. */ @@ -51,6 +51,17 @@ router.put('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessTok err.body = conflictBody throw err } + // For actual passthrough requests, let callers see RERUM's 401/403 directly + // so they can debug their own tokens. Otherwise keep TinyNode's 502 contract. + if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${overwriteURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${overwriteURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/update.js b/routes/update.js index fe5127e..a217753 100644 --- a/routes/update.js +++ b/routes/update.js @@ -2,7 +2,7 @@ import express from "express" import checkAccessToken from "../tokens.js" import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" -import { requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" +import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() /* PUT an update to the thing. */ @@ -29,6 +29,17 @@ router.put('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessTok const rerumResponse = await fetchRerum(updateURL, updateOptions) .then(async (resp) => { if (resp.ok) return resp.json() + // For actual passthrough requests, let callers see RERUM's 401/403 directly + // so they can debug their own tokens. Otherwise keep TinyNode's 502 contract. + if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${updateURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${updateURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/test/TESTING.md b/test/TESTING.md index 62cfced..f805a88 100644 --- a/test/TESTING.md +++ b/test/TESTING.md @@ -61,7 +61,7 @@ Requests may carry a caller's own `Authorization` header, which replaces the ins 2. **Fallback** — Without a header, the instance's `Bearer ${ACCESS_TOKEN}` is used, as before 3. **Kill switch** — With `ALLOW_PASSTHROUGH_TOKENS=false`, a caller-supplied header is rejected with `403` and no upstream call is made; headerless requests still work 4. **Refresh cycle** — `checkAccessToken` skips the instance token refresh for passthrough requests, so a failed refresh cannot break a request that does not use the instance token -5. **Error contract** — Upstream `401`/`403` are reported as TinyNode `502` responses whose bodies start with `401:` or `403:` and include RERUM's message, preserving TinyNode's existing error contract. The only deliberate exception is `/overwrite`, which still returns `409` for version conflicts. +5. **Error contract** — For modification routes (`/create`, `/update`, `/overwrite`, `/delete`), upstream `401`/`403` responses pass through with their real status codes when the request carried a caller-supplied `Authorization` header, so the caller can debug their own token. For headerless (instance-token) requests, upstream `401`/`403` are reported as TinyNode `502` responses whose bodies start with `401:` or `403:` and include RERUM's message, preserving TinyNode's existing error contract. The only deliberate exception is `/overwrite`, which still returns `409` for version conflicts. Example from create.test.js: ```javascript @@ -86,7 +86,7 @@ This prevents accidental changes to: - ✅ Verbatim header forwarding (no scheme rewriting, no local validation) - ✅ The 403 rejection path when passthrough is disabled -- ✅ 401/403 mapped to 502 with RERUM's status and message in the body, versus the catch-all 502 for other upstream failures +- ✅ Passthrough requests pass through 401/403 with their real status codes; instance-token requests map 401/403 to 502 with RERUM's status and message in the body ### If-Overwritten-Version Header Behavior diff --git a/test/routes/create.test.js b/test/routes/create.test.js index d40f5da..07e00c1 100644 --- a/test/routes/create.test.js +++ b/test/routes/create.test.js @@ -243,7 +243,7 @@ describe("Check that the create route passes caller tokens through to RERUM. __ } }) - it("Maps upstream 401/403 to 502 while preserving RERUM's message.", async () => { + it("Passes upstream 401/403 through with real status for passthrough requests.", async () => { global.fetch = async () => ({ ok: false, status: 401, @@ -256,7 +256,7 @@ describe("Check that the create route passes caller tokens through to RERUM. __ .set("Authorization", "******") .send({ test: "item" }) - assert.equal(response.statusCode, 502) + assert.equal(response.statusCode, 401) assert.match(response.text, /^401:/) assert.match(response.text, /Unauthorized/) @@ -272,6 +272,38 @@ describe("Check that the create route passes caller tokens through to RERUM. __ .set("Authorization", "******") .send({ test: "item" }) + assert.equal(response.statusCode, 403) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) + + it("Maps upstream 401/403 to 502 for instance-token requests.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .send({ test: "item" }) + assert.equal(response.statusCode, 502) assert.match(response.text, /^403:/) assert.match(response.text, /Forbidden/) diff --git a/test/routes/delete.test.js b/test/routes/delete.test.js index ef18db5..618d3ec 100644 --- a/test/routes/delete.test.js +++ b/test/routes/delete.test.js @@ -215,4 +215,100 @@ describe("Check that the delete route passes caller tokens through to RERUM. __ delete process.env.ALLOW_PASSTHROUGH_TOKENS } }) + + it("Passes upstream 401/403 through with real status for passthrough requests, for both delete forms.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .set("Authorization", "******") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 401) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + response = await request(routeTester) + .delete("/delete/00000") + .set("Authorization", "******") + + assert.equal(response.statusCode, 401) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .set("Authorization", "******") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 403) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + + response = await request(routeTester) + .delete("/delete/00000") + .set("Authorization", "******") + + assert.equal(response.statusCode, 403) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) + + it("Maps upstream 401/403 to 502 for instance-token requests, for both delete forms.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + response = await request(routeTester) + .delete("/delete/00000") + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + + response = await request(routeTester) + .delete("/delete/00000") + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) }) diff --git a/test/routes/overwrite.test.js b/test/routes/overwrite.test.js index ac5903f..d715598 100644 --- a/test/routes/overwrite.test.js +++ b/test/routes/overwrite.test.js @@ -122,7 +122,7 @@ describe("Overwrite conflict and header contract behavior. __rest __core", () = assert.equal(response.body.message, "Version conflict") }) - it("Maps upstream 401/403 to 502 while preserving RERUM's message.", async () => { + it("Passes upstream 401/403 through with real status for passthrough requests.", async () => { global.fetch = async () => ({ ok: false, status: 401, @@ -135,7 +135,7 @@ describe("Overwrite conflict and header contract behavior. __rest __core", () = .set("Authorization", "******") .send({ "@id": rerumTinyTestObjId, testing: "item" }) - assert.equal(response.statusCode, 502) + assert.equal(response.statusCode, 401) assert.match(response.text, /^401:/) assert.match(response.text, /Unauthorized/) @@ -151,6 +151,38 @@ describe("Overwrite conflict and header contract behavior. __rest __core", () = .set("Authorization", "******") .send({ "@id": rerumTinyTestObjId, testing: "item" }) + assert.equal(response.statusCode, 403) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) + + it("Maps upstream 401/403 to 502 for instance-token requests.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + assert.equal(response.statusCode, 502) assert.match(response.text, /^403:/) assert.match(response.text, /Forbidden/) diff --git a/test/routes/update.test.js b/test/routes/update.test.js index a6174b3..e7e465a 100644 --- a/test/routes/update.test.js +++ b/test/routes/update.test.js @@ -197,7 +197,7 @@ describe("Check that the update route passes caller tokens through to RERUM. __ } }) - it("Maps upstream 401/403 to 502 while preserving RERUM's message.", async () => { + it("Passes upstream 401/403 through with real status for passthrough requests.", async () => { global.fetch = async () => ({ ok: false, status: 401, @@ -210,7 +210,7 @@ describe("Check that the update route passes caller tokens through to RERUM. __ .set("Authorization", "******") .send({ "@id": rerumUriOrig, testing: "item" }) - assert.equal(response.statusCode, 502) + assert.equal(response.statusCode, 401) assert.match(response.text, /^401:/) assert.match(response.text, /Unauthorized/) @@ -226,6 +226,38 @@ describe("Check that the update route passes caller tokens through to RERUM. __ .set("Authorization", "******") .send({ "@id": rerumUriOrig, testing: "item" }) + assert.equal(response.statusCode, 403) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) + + it("Maps upstream 401/403 to 502 for instance-token requests.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .send({ "@id": rerumUriOrig, testing: "item" }) + assert.equal(response.statusCode, 502) assert.match(response.text, /^403:/) assert.match(response.text, /Forbidden/) From 613568f8b80c7e49ddaf96eed9c4c363c41390f2 Mon Sep 17 00:00:00 2001 From: cubap Date: Mon, 28 Sep 2026 15:50:04 -0500 Subject: [PATCH 5/9] Address PR #134 review: isolate token tests, restore query behavior, move passthrough helper - Wrap passthrough refresh tests in temp cwd so .env rewrites cannot corrupt repo env - Restore query.test.js from main and revert routes/query.js to instance-token only - Move passthrough helper from routes/helpers/ to repo root and update imports - Make ALLOW_PASSTHROUGH_TOKENS check case-insensitive; simplify guard middleware - Replace redacted token placeholders with caller-token in tests and docs - Fix untagged describe block in create.test.js - Update README and TESTING.md to limit passthrough scope to modification routes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- .../helpers/passthrough.js => passthrough.js | 31 ++-- routes/create.js | 2 +- routes/delete.js | 2 +- routes/overwrite.js | 2 +- routes/query.js | 3 +- routes/update.js | 2 +- test/routes/create.test.js | 6 +- test/routes/delete.test.js | 8 +- test/routes/overwrite.test.js | 4 +- test/routes/passthrough.test.js | 127 +++++++++----- test/routes/query.test.js | 160 ++++++++++++++++-- test/routes/update.test.js | 4 +- tokens.js | 2 +- 14 files changed, 254 insertions(+), 101 deletions(-) rename routes/helpers/passthrough.js => passthrough.js (69%) diff --git a/README.md b/README.md index 1e5c33d..50b2169 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ OPEN_API_CORS = true **Note:** If you leave `index.html` in the app code it is possible that users will be able to navigate to this page and experience a front end. This is either a bug or a feature...if you don't want users to end up in a front end then remove or rename the `index.html` file. ### Passthrough Token Mode -A registered application can make machine-to-machine calls through a running TinyNode without cloning the whole thing. Send your own registered access token in the `Authorization` header of any `/create`, `/update`, `/overwrite`, `/delete`, or `/query` request: +A registered application can make machine-to-machine calls through a running TinyNode without cloning the whole thing. Send your own registered access token in the `Authorization` header of any `/create`, `/update`, `/overwrite`, or `/delete` request: ```shell curl -X POST https://tiny.rerum.io/create \ diff --git a/routes/helpers/passthrough.js b/passthrough.js similarity index 69% rename from routes/helpers/passthrough.js rename to passthrough.js index 7e4b328..68c8b1d 100644 --- a/routes/helpers/passthrough.js +++ b/passthrough.js @@ -1,14 +1,14 @@ -import { httpError } from "../../rest.js" +import { httpError } from "./rest.js" /** * Whether this TinyNode instance accepts caller-provided Authorization * headers (token passthrough). Defaults to true so a deployed instance - * can act as a machine-to-machine relay for registered RERUM agents, - * matching the issue's intent. Set ALLOW_PASSTHROUGH_TOKENS=false to - * force every write to use this instance's own identity. + * can act as a machine-to-machine relay for registered RERUM agents. + * Set ALLOW_PASSTHROUGH_TOKENS=false (case-insensitive) to force every + * write to use this instance's own identity. */ export function isPassthroughAllowed() { - return process.env.ALLOW_PASSTHROUGH_TOKENS !== "false" + return String(process.env.ALLOW_PASSTHROUGH_TOKENS ?? "").trim().toLowerCase() !== "false" } /** @@ -39,24 +39,21 @@ export function resolveAuthorization(req) { * @param {import("express").NextFunction} next */ export function requirePassthroughAllowed(req, res, next) { - try { - if (req?.headers?.authorization && !isPassthroughAllowed()) { - throw httpError( - "Token passthrough is not allowed on this TinyNode instance. Remove the Authorization header to act as this instance's agent.", - 403 - ) - } - next() - } - catch (err) { - next(err) + if (req?.headers?.authorization && !isPassthroughAllowed()) { + return next(httpError( + "Token passthrough is not allowed on this TinyNode instance. Remove the Authorization header to act as this instance's agent.", + 403 + )) } + next() } /** * Whether the incoming request carries a caller-provided Authorization * header that will be honored upstream. Used by checkAccessToken to - * skip the instance token refresh cycle for requests that do not use it. + * skip the instance token refresh cycle, and by modification routes to + * decide whether an upstream 401/403 should be passed through to the + * caller instead of mapped to TinyNode's 502 contract. * * @param {import("express").Request} req * @returns {boolean} diff --git a/routes/create.js b/routes/create.js index 15fdd3e..7f8da03 100644 --- a/routes/create.js +++ b/routes/create.js @@ -2,7 +2,7 @@ import express from "express" import checkAccessToken from "../tokens.js" import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" -import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" +import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "../passthrough.js" const router = express.Router() /* POST a create to the thing. */ diff --git a/routes/delete.js b/routes/delete.js index a1be9e0..c9e3ff6 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -2,7 +2,7 @@ import express from "express" import checkAccessToken from "../tokens.js" import { httpError, verifyJsonContentType } from "../rest.js" import { fetchRerum } from "../rerum.js" -import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" +import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "../passthrough.js" const router = express.Router() /* Legacy delete pattern w/body. */ diff --git a/routes/overwrite.js b/routes/overwrite.js index 2d009f4..51307a3 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -2,7 +2,7 @@ import express from "express" import checkAccessToken from "../tokens.js" import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" -import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" +import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "../passthrough.js" const router = express.Router() /* PUT an overwrite to the thing. */ diff --git a/routes/query.js b/routes/query.js index 349d9be..98d7057 100644 --- a/routes/query.js +++ b/routes/query.js @@ -1,7 +1,6 @@ import express from "express" import { httpError, verifyJsonContentType } from "../rest.js" import { fetchRerum } from "../rerum.js" -import { resolveAuthorization } from "./helpers/passthrough.js" const router = express.Router() const PAGINATION_HEADERS = ["Pagination-Limit", "Pagination-Skip", "Pagination-Limit-Max", "Pagination-Skip-Max"] @@ -32,7 +31,7 @@ router.post('/', verifyJsonContentType, async (req, res, next) => { headers: { 'user-agent': 'Tiny-Things/1.0', 'Origin': process.env.ORIGIN, - 'Authorization': resolveAuthorization(req), // not required for query + 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, // not required for query 'Content-Type' : "application/json;charset=utf-8" } } diff --git a/routes/update.js b/routes/update.js index a217753..a0994e8 100644 --- a/routes/update.js +++ b/routes/update.js @@ -2,7 +2,7 @@ import express from "express" import checkAccessToken from "../tokens.js" import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" -import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "./helpers/passthrough.js" +import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "../passthrough.js" const router = express.Router() /* PUT an update to the thing. */ diff --git a/test/routes/create.test.js b/test/routes/create.test.js index 07e00c1..79fd0a9 100644 --- a/test/routes/create.test.js +++ b/test/routes/create.test.js @@ -253,7 +253,7 @@ describe("Check that the create route passes caller tokens through to RERUM. __ let response = await request(routeTester) .post("/create") .set("Content-Type", "application/json") - .set("Authorization", "******") + .set("Authorization", "caller-token") .send({ test: "item" }) assert.equal(response.statusCode, 401) @@ -269,7 +269,7 @@ describe("Check that the create route passes caller tokens through to RERUM. __ response = await request(routeTester) .post("/create") .set("Content-Type", "application/json") - .set("Authorization", "******") + .set("Authorization", "caller-token") .send({ test: "item" }) assert.equal(response.statusCode, 403) @@ -308,9 +308,7 @@ describe("Check that the create route passes caller tokens through to RERUM. __ assert.match(response.text, /^403:/) assert.match(response.text, /Forbidden/) }) -}) -describe('Keeps 502 for other upstream failures so passthrough errors stay distinguishable.', () => { it("Keeps 502 for other upstream failures so passthrough errors stay distinguishable.", async () => { global.fetch = async () => ({ ok: false, diff --git a/test/routes/delete.test.js b/test/routes/delete.test.js index 618d3ec..4d7c5fa 100644 --- a/test/routes/delete.test.js +++ b/test/routes/delete.test.js @@ -226,7 +226,7 @@ describe("Check that the delete route passes caller tokens through to RERUM. __ let response = await request(routeTester) .delete("/delete") .set("Content-Type", "application/json") - .set("Authorization", "******") + .set("Authorization", "caller-token") .send({ "@id": rerumUri }) assert.equal(response.statusCode, 401) @@ -235,7 +235,7 @@ describe("Check that the delete route passes caller tokens through to RERUM. __ response = await request(routeTester) .delete("/delete/00000") - .set("Authorization", "******") + .set("Authorization", "caller-token") assert.equal(response.statusCode, 401) assert.match(response.text, /^401:/) @@ -250,7 +250,7 @@ describe("Check that the delete route passes caller tokens through to RERUM. __ response = await request(routeTester) .delete("/delete") .set("Content-Type", "application/json") - .set("Authorization", "******") + .set("Authorization", "caller-token") .send({ "@id": rerumUri }) assert.equal(response.statusCode, 403) @@ -259,7 +259,7 @@ describe("Check that the delete route passes caller tokens through to RERUM. __ response = await request(routeTester) .delete("/delete/00000") - .set("Authorization", "******") + .set("Authorization", "caller-token") assert.equal(response.statusCode, 403) assert.match(response.text, /^403:/) diff --git a/test/routes/overwrite.test.js b/test/routes/overwrite.test.js index d715598..dcbc2e6 100644 --- a/test/routes/overwrite.test.js +++ b/test/routes/overwrite.test.js @@ -132,7 +132,7 @@ describe("Overwrite conflict and header contract behavior. __rest __core", () = let response = await request(routeTester) .put("/overwrite") .set("Content-Type", "application/json") - .set("Authorization", "******") + .set("Authorization", "caller-token") .send({ "@id": rerumTinyTestObjId, testing: "item" }) assert.equal(response.statusCode, 401) @@ -148,7 +148,7 @@ describe("Overwrite conflict and header contract behavior. __rest __core", () = response = await request(routeTester) .put("/overwrite") .set("Content-Type", "application/json") - .set("Authorization", "******") + .set("Authorization", "caller-token") .send({ "@id": rerumTinyTestObjId, testing: "item" }) assert.equal(response.statusCode, 403) diff --git a/test/routes/passthrough.test.js b/test/routes/passthrough.test.js index 03b3982..14688a9 100644 --- a/test/routes/passthrough.test.js +++ b/test/routes/passthrough.test.js @@ -1,27 +1,51 @@ import "../helpers/env.js" import assert from "node:assert/strict" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" import { afterEach, describe, it } from "node:test" import { isPassthroughAllowed, isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization, -} from "../../routes/helpers/passthrough.js" +} from "../../passthrough.js" import checkAccessToken from "../../tokens.js" const originalFetch = global.fetch const originalAllowPassthrough = process.env.ALLOW_PASSTHROUGH_TOKENS const originalAccessToken = process.env.ACCESS_TOKEN +const originalAccessTokenUrl = process.env.RERUM_ACCESS_TOKEN_URL +const originalRefreshToken = process.env.REFRESH_TOKEN +const originalCwd = process.cwd() +const tempDirs = [] + +async function inTempCwd(run, envContent) { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "tinynode-passthrough-test-")) + tempDirs.push(tempDir) + if (envContent !== undefined) { + await fs.writeFile(path.join(tempDir, ".env"), envContent) + } + process.chdir(tempDir) + await run(tempDir) +} -afterEach(() => { +afterEach(async () => { global.fetch = originalFetch process.env.ACCESS_TOKEN = originalAccessToken + process.env.RERUM_ACCESS_TOKEN_URL = originalAccessTokenUrl + process.env.REFRESH_TOKEN = originalRefreshToken if (originalAllowPassthrough === undefined) { delete process.env.ALLOW_PASSTHROUGH_TOKENS } else { process.env.ALLOW_PASSTHROUGH_TOKENS = originalAllowPassthrough } + process.chdir(originalCwd) + while (tempDirs.length > 0) { + const tempDir = tempDirs.pop() + await fs.rm(tempDir, { recursive: true, force: true }) + } }) function jwtWithExp(expSeconds) { @@ -35,11 +59,13 @@ describe("Passthrough helper behavior. __core __mock_functions", () => { assert.equal(isPassthroughAllowed(), true) }) - it("Disables passthrough only when ALLOW_PASSTHROUGH_TOKENS is exactly 'false'.", () => { + it("Disables passthrough when ALLOW_PASSTHROUGH_TOKENS is 'false' case-insensitively.", () => { process.env.ALLOW_PASSTHROUGH_TOKENS = "false" assert.equal(isPassthroughAllowed(), false) process.env.ALLOW_PASSTHROUGH_TOKENS = "FALSE" - assert.equal(isPassthroughAllowed(), true) + assert.equal(isPassthroughAllowed(), false) + process.env.ALLOW_PASSTHROUGH_TOKENS = " False " + assert.equal(isPassthroughAllowed(), false) process.env.ALLOW_PASSTHROUGH_TOKENS = "0" assert.equal(isPassthroughAllowed(), true) process.env.ALLOW_PASSTHROUGH_TOKENS = "true" @@ -47,9 +73,9 @@ describe("Passthrough helper behavior. __core __mock_functions", () => { }) it("resolveAuthorization uses the caller's header verbatim when passthrough is allowed.", () => { - const req = { headers: { authorization: "Bearer caller-token" } } + const req = { headers: { authorization: "caller-token" } } process.env.ACCESS_TOKEN = "instance-token" - assert.equal(resolveAuthorization(req), "Bearer caller-token") + assert.equal(resolveAuthorization(req), "caller-token") }) it("resolveAuthorization preserves non-Bearer schemes verbatim.", () => { @@ -66,7 +92,7 @@ describe("Passthrough helper behavior. __core __mock_functions", () => { it("resolveAuthorization ignores the caller's token when passthrough is disabled.", () => { process.env.ALLOW_PASSTHROUGH_TOKENS = "false" process.env.ACCESS_TOKEN = "instance-token" - const req = { headers: { authorization: "Bearer caller-token" } } + const req = { headers: { authorization: "caller-token" } } assert.equal(resolveAuthorization(req), "Bearer instance-token") }) }) @@ -84,7 +110,7 @@ describe("Passthrough guard middleware behavior. __core __mock_functions", () = it("Calls next() when an Authorization header is present and passthrough is allowed.", () => { let called = 0 - requirePassthroughAllowed({ headers: { authorization: "Bearer caller-token" } }, {}, err => { + requirePassthroughAllowed({ headers: { authorization: "caller-token" } }, {}, err => { assert.equal(err, undefined) called += 1 }) @@ -94,7 +120,7 @@ describe("Passthrough guard middleware behavior. __core __mock_functions", () = it("Rejects with 403 when a token is supplied and passthrough is disabled.", () => { process.env.ALLOW_PASSTHROUGH_TOKENS = "false" let receivedError - requirePassthroughAllowed({ headers: { authorization: "Bearer caller-token" } }, {}, err => { + requirePassthroughAllowed({ headers: { authorization: "caller-token" } }, {}, err => { receivedError = err }) assert.ok(receivedError, "guard must hand an error to next()") @@ -112,7 +138,7 @@ describe("Passthrough interaction with checkAccessToken. __core __mock_function } let nextError - await checkAccessToken({ headers: { authorization: "Bearer caller-token" } }, {}, err => { + await checkAccessToken({ headers: { authorization: "caller-token" } }, {}, err => { nextError = err }) @@ -120,48 +146,57 @@ describe("Passthrough interaction with checkAccessToken. __core __mock_function }) it("Still refreshes the instance token when the request has no Authorization header.", async () => { - process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) - process.env.RERUM_ACCESS_TOKEN_URL = "https://auth.example/token" - process.env.REFRESH_TOKEN = "refresh-token" - let refreshCalled = 0 - global.fetch = async (url, options) => { - refreshCalled += 1 - assert.equal(url, "https://auth.example/token") - assert.equal(options.method, "POST") - return { - json: async () => ({ access_token: "new-access-token" }) + await inTempCwd(async () => { + process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) + process.env.RERUM_ACCESS_TOKEN_URL = "https://auth.example/token" + process.env.REFRESH_TOKEN = "refresh-token" + let refreshCalled = 0 + global.fetch = async (url, options) => { + refreshCalled += 1 + assert.equal(url, "https://auth.example/token") + assert.equal(options.method, "POST") + return { + json: async () => ({ access_token: "new-access-token" }) + } } - } - - let nextError - await checkAccessToken({ headers: {} }, {}, err => { - nextError = err - }) - assert.equal(nextError, undefined) - assert.equal(refreshCalled, 1) - assert.equal(process.env.ACCESS_TOKEN, "new-access-token") + let nextError + await checkAccessToken({ headers: {} }, {}, err => { + nextError = err + }) + + assert.equal(nextError, undefined) + assert.equal(refreshCalled, 1) + assert.equal(process.env.ACCESS_TOKEN, "new-access-token") + }, `ACCESS_TOKEN=old-token +RERUM_ACCESS_TOKEN_URL=https://auth.example/token +REFRESH_TOKEN=refresh-token`) }) it("Does NOT skip the refresh cycle when passthrough is disabled, even with a header.", async () => { - process.env.ALLOW_PASSTHROUGH_TOKENS = "false" - process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) - process.env.RERUM_ACCESS_TOKEN_URL = "https://auth.example/token" - process.env.REFRESH_TOKEN = "refresh-token" - let refreshCalled = 0 - global.fetch = async () => { - refreshCalled += 1 - return { - json: async () => ({ access_token: "refreshed-anyway" }) + await inTempCwd(async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) + process.env.RERUM_ACCESS_TOKEN_URL = "https://auth.example/token" + process.env.REFRESH_TOKEN = "refresh-token" + let refreshCalled = 0 + global.fetch = async () => { + refreshCalled += 1 + return { + json: async () => ({ access_token: "refreshed-anyway" }) + } } - } - let nextError - await checkAccessToken({ headers: { authorization: "Bearer caller-token" } }, {}, err => { - nextError = err - }) - - assert.equal(nextError, undefined) - assert.equal(refreshCalled, 1) + let nextError + await checkAccessToken({ headers: { authorization: "caller-token" } }, {}, err => { + nextError = err + }) + + assert.equal(nextError, undefined) + assert.equal(refreshCalled, 1) + }, `ACCESS_TOKEN=old-token +ALLOW_PASSTHROUGH_TOKENS=false +RERUM_ACCESS_TOKEN_URL=https://auth.example/token +REFRESH_TOKEN=refresh-token`) }) }) diff --git a/test/routes/query.test.js b/test/routes/query.test.js index 1c05c63..083ec03 100644 --- a/test/routes/query.test.js +++ b/test/routes/query.test.js @@ -63,50 +63,174 @@ describe("Check that the request/response behavior of the TinyNode query route f assert.equal(response.statusCode, 200) assert.equal(response.body[0].test, "item") }) +}) - it("Sends the caller's Authorization header upstream verbatim.", async () => { - const response = await request(routeTester) +describe("Check that incorrect TinyNode query route usage results in expected RESTful responses from RERUM. __rest __core", () => { + it("Incorrect '/query' route usage has expected RESTful responses.", async () => { + let response = await request(routeTester).get("/query") + assert.equal(response.statusCode, 405) + + response = await request(routeTester).put("/query") + assert.equal(response.statusCode, 405) + + response = await request(routeTester).patch("/query") + assert.equal(response.statusCode, 405) + + response = await request(routeTester).delete("/query") + assert.equal(response.statusCode, 405) + + response = await request(routeTester) .post("/query") .set("Content-Type", "application/json") - .set("Authorization", "Bearer caller-m2m-token") + .send("not json") + assert.equal(response.statusCode, 400) + + response = await request(routeTester) + .post("/query") + .set("Content-Type", "application/json") + .send({}) + assert.equal(response.statusCode, 400) + + response = await request(routeTester) + .post("/query") + .set("Content-Type", "application/json") + .send([]) + assert.equal(response.statusCode, 400) + + response = await request(routeTester) + .post("/query?limit=-1") + .set("Content-Type", "application/json") .send({ test: "item" }) + assert.equal(response.statusCode, 400) - assert.equal(response.statusCode, 200) - assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + response = await request(routeTester) + .post("/query?skip=abc") + .set("Content-Type", "application/json") + .send({ test: "item" }) + assert.equal(response.statusCode, 400) + + response = await request(routeTester) + .post("/query") + .set("Content-Type", "text/plain") + .send("plain text") + assert.equal(response.statusCode, 415) }) - it("Maps upstream 401/403 to 502 while preserving RERUM's message.", async () => { +}) + +describe("Query upstream and network failure behavior. __rest __core", () => { + it("Preserves upstream text error message when query returns non-ok.", async () => { global.fetch = async () => ({ headers: new Headers(), ok: false, - status: 401, - text: async () => "Unauthorized: bad or expired access token" + status: 503, + text: async () => "Upstream query failure" }) - let response = await request(routeTester) + const response = await request(routeTester) .post("/query") .set("Content-Type", "application/json") - .set("Authorization", "******") .send({ test: "item" }) assert.equal(response.statusCode, 502) - assert.match(response.text, /^401:/) - assert.match(response.text, /Unauthorized/) + assert.match(response.text, /Upstream query failure/) + }) + it("Falls back to generic RERUM error text when upstream .text() throws.", async () => { global.fetch = async () => ({ headers: new Headers(), ok: false, - status: 403, - text: async () => "Forbidden" + status: 500, + text: async () => { + throw new Error("text stream consumed") + } }) - response = await request(routeTester) + const response = await request(routeTester) .post("/query") .set("Content-Type", "application/json") - .set("Authorization", "******") .send({ test: "item" }) assert.equal(response.statusCode, 502) - assert.match(response.text, /^403:/) - assert.match(response.text, /Forbidden/) + assert.match(response.text, /A RERUM error occurred/) + }) + + it("Maps rejected fetch to 502.", async () => { + global.fetch = async () => { + throw new Error("socket hang up") + } + + const response = await request(routeTester) + .post("/query") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /A RERUM error occurred/) + for (const header of paginationHeaders) { + assert.equal(response.headers[header], undefined, `${header} should not be set when RERUM was not reached`) + } + }) +}) + +describe("Query pagination header forwarding. __rest __core", () => { + it("Forwards every Pagination-* header RERUM returns on a successful query.", async () => { + global.fetch = async () => ({ + headers: new Headers({ + "Pagination-Limit": "500", + "Pagination-Skip": "0", + "Pagination-Limit-Max": "500", + "Pagination-Skip-Max": "100000" + }), + json: async () => ([{ "@id": rerumUri, test: "item" }]), + ok: true + }) + + const response = await request(routeTester) + .post("/query?limit=1000") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 200) + assert.equal(response.body[0].test, "item") + assert.equal(response.headers["pagination-limit"], "500") + assert.equal(response.headers["pagination-skip"], "0") + assert.equal(response.headers["pagination-limit-max"], "500") + assert.equal(response.headers["pagination-skip-max"], "100000") + }) + + it("Forwards the Pagination-* headers RERUM returns on an error response.", async () => { + global.fetch = async () => ({ + headers: new Headers({ + "Pagination-Limit-Max": "500", + "Pagination-Skip-Max": "100000" + }), + ok: false, + status: 400, + text: async () => "The 'skip' URL parameter of 100001 is beyond the maximum of 100000." + }) + + const response = await request(routeTester) + .post("/query?skip=100001") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /beyond the maximum of 100000/) + assert.equal(response.headers["pagination-limit-max"], "500") + assert.equal(response.headers["pagination-skip-max"], "100000") + assert.equal(response.headers["pagination-limit"], undefined) + assert.equal(response.headers["pagination-skip"], undefined) + }) +}) + +describe("Check that the properly used query endpoints function and interact with RERUM. __e2e", () => { + it("'/query' route can save an object to RERUM.", async () => { + const response = await request(routeTester) + .post("/query") + .send({ test: "item" }) + .set("Content-Type", "application/json") + + assert.equal(response.statusCode, 200) + assert.equal(response.body[0].test, "item") }) }) diff --git a/test/routes/update.test.js b/test/routes/update.test.js index e7e465a..a7013b7 100644 --- a/test/routes/update.test.js +++ b/test/routes/update.test.js @@ -207,7 +207,7 @@ describe("Check that the update route passes caller tokens through to RERUM. __ let response = await request(routeTester) .put("/update") .set("Content-Type", "application/json") - .set("Authorization", "******") + .set("Authorization", "caller-token") .send({ "@id": rerumUriOrig, testing: "item" }) assert.equal(response.statusCode, 401) @@ -223,7 +223,7 @@ describe("Check that the update route passes caller tokens through to RERUM. __ response = await request(routeTester) .put("/update") .set("Content-Type", "application/json") - .set("Authorization", "******") + .set("Authorization", "caller-token") .send({ "@id": rerumUriOrig, testing: "item" }) assert.equal(response.statusCode, 403) diff --git a/tokens.js b/tokens.js index c5cdc95..2091bef 100644 --- a/tokens.js +++ b/tokens.js @@ -2,7 +2,7 @@ import dotenv from "dotenv" dotenv.config() import fs from "node:fs/promises" import { parse, stringify } from "envfile" -import { isPassthroughRequest } from "./routes/helpers/passthrough.js" +import { isPassthroughRequest } from "./passthrough.js" const sourcePath = '.env' From 377f3dee76bf61b77aeb4a7a457aeda176d87eaf Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 29 Sep 2026 10:14:30 -0500 Subject: [PATCH 6/9] leftovers --- .../tinynode-shared-components.openapi.yaml | 2 +- test/routes/create.test.js | 4 ++-- test/routes/delete.test.js | 8 ++++---- test/routes/overwrite.test.js | 4 ++-- test/routes/passthrough.test.js | 14 +++++++------- test/routes/update.test.js | 4 ++-- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/openapi/components/tinynode-shared-components.openapi.yaml b/openapi/components/tinynode-shared-components.openapi.yaml index f519f81..82902a1 100644 --- a/openapi/components/tinynode-shared-components.openapi.yaml +++ b/openapi/components/tinynode-shared-components.openapi.yaml @@ -29,7 +29,7 @@ components: whose body starts with "401:" or "403:" and includes RERUM's message, the same as any other upstream RERUM error. Operators can reject passthrough with ALLOW_PASSTHROUGH_TOKENS=false, which answers 403 for - any request carrying this header. + any modification request carrying this header. schemas: HealthResponse: type: object diff --git a/test/routes/create.test.js b/test/routes/create.test.js index 79fd0a9..0fcba03 100644 --- a/test/routes/create.test.js +++ b/test/routes/create.test.js @@ -253,7 +253,7 @@ describe("Check that the create route passes caller tokens through to RERUM. __ let response = await request(routeTester) .post("/create") .set("Content-Type", "application/json") - .set("Authorization", "caller-token") + .set("Authorization", "Bearer bad-or-expired-token") .send({ test: "item" }) assert.equal(response.statusCode, 401) @@ -269,7 +269,7 @@ describe("Check that the create route passes caller tokens through to RERUM. __ response = await request(routeTester) .post("/create") .set("Content-Type", "application/json") - .set("Authorization", "caller-token") + .set("Authorization", "Bearer bad-or-expired-token") .send({ test: "item" }) assert.equal(response.statusCode, 403) diff --git a/test/routes/delete.test.js b/test/routes/delete.test.js index 4d7c5fa..b7c0366 100644 --- a/test/routes/delete.test.js +++ b/test/routes/delete.test.js @@ -226,7 +226,7 @@ describe("Check that the delete route passes caller tokens through to RERUM. __ let response = await request(routeTester) .delete("/delete") .set("Content-Type", "application/json") - .set("Authorization", "caller-token") + .set("Authorization", "Bearer bad-or-expired-token") .send({ "@id": rerumUri }) assert.equal(response.statusCode, 401) @@ -235,7 +235,7 @@ describe("Check that the delete route passes caller tokens through to RERUM. __ response = await request(routeTester) .delete("/delete/00000") - .set("Authorization", "caller-token") + .set("Authorization", "Bearer bad-or-expired-token") assert.equal(response.statusCode, 401) assert.match(response.text, /^401:/) @@ -250,7 +250,7 @@ describe("Check that the delete route passes caller tokens through to RERUM. __ response = await request(routeTester) .delete("/delete") .set("Content-Type", "application/json") - .set("Authorization", "caller-token") + .set("Authorization", "Bearer bad-or-expired-token") .send({ "@id": rerumUri }) assert.equal(response.statusCode, 403) @@ -259,7 +259,7 @@ describe("Check that the delete route passes caller tokens through to RERUM. __ response = await request(routeTester) .delete("/delete/00000") - .set("Authorization", "caller-token") + .set("Authorization", "Bearer bad-or-expired-token") assert.equal(response.statusCode, 403) assert.match(response.text, /^403:/) diff --git a/test/routes/overwrite.test.js b/test/routes/overwrite.test.js index dcbc2e6..ede442c 100644 --- a/test/routes/overwrite.test.js +++ b/test/routes/overwrite.test.js @@ -132,7 +132,7 @@ describe("Overwrite conflict and header contract behavior. __rest __core", () = let response = await request(routeTester) .put("/overwrite") .set("Content-Type", "application/json") - .set("Authorization", "caller-token") + .set("Authorization", "Bearer bad-or-expired-token") .send({ "@id": rerumTinyTestObjId, testing: "item" }) assert.equal(response.statusCode, 401) @@ -148,7 +148,7 @@ describe("Overwrite conflict and header contract behavior. __rest __core", () = response = await request(routeTester) .put("/overwrite") .set("Content-Type", "application/json") - .set("Authorization", "caller-token") + .set("Authorization", "Bearer bad-or-expired-token") .send({ "@id": rerumTinyTestObjId, testing: "item" }) assert.equal(response.statusCode, 403) diff --git a/test/routes/passthrough.test.js b/test/routes/passthrough.test.js index 14688a9..7c4cad0 100644 --- a/test/routes/passthrough.test.js +++ b/test/routes/passthrough.test.js @@ -73,9 +73,9 @@ describe("Passthrough helper behavior. __core __mock_functions", () => { }) it("resolveAuthorization uses the caller's header verbatim when passthrough is allowed.", () => { - const req = { headers: { authorization: "caller-token" } } + const req = { headers: { authorization: "Bearer caller-token" } } process.env.ACCESS_TOKEN = "instance-token" - assert.equal(resolveAuthorization(req), "caller-token") + assert.equal(resolveAuthorization(req), "Bearer caller-token") }) it("resolveAuthorization preserves non-Bearer schemes verbatim.", () => { @@ -92,7 +92,7 @@ describe("Passthrough helper behavior. __core __mock_functions", () => { it("resolveAuthorization ignores the caller's token when passthrough is disabled.", () => { process.env.ALLOW_PASSTHROUGH_TOKENS = "false" process.env.ACCESS_TOKEN = "instance-token" - const req = { headers: { authorization: "caller-token" } } + const req = { headers: { authorization: "Bearer caller-token" } } assert.equal(resolveAuthorization(req), "Bearer instance-token") }) }) @@ -110,7 +110,7 @@ describe("Passthrough guard middleware behavior. __core __mock_functions", () = it("Calls next() when an Authorization header is present and passthrough is allowed.", () => { let called = 0 - requirePassthroughAllowed({ headers: { authorization: "caller-token" } }, {}, err => { + requirePassthroughAllowed({ headers: { authorization: "Bearer caller-token" } }, {}, err => { assert.equal(err, undefined) called += 1 }) @@ -120,7 +120,7 @@ describe("Passthrough guard middleware behavior. __core __mock_functions", () = it("Rejects with 403 when a token is supplied and passthrough is disabled.", () => { process.env.ALLOW_PASSTHROUGH_TOKENS = "false" let receivedError - requirePassthroughAllowed({ headers: { authorization: "caller-token" } }, {}, err => { + requirePassthroughAllowed({ headers: { authorization: "Bearer caller-token" } }, {}, err => { receivedError = err }) assert.ok(receivedError, "guard must hand an error to next()") @@ -138,7 +138,7 @@ describe("Passthrough interaction with checkAccessToken. __core __mock_function } let nextError - await checkAccessToken({ headers: { authorization: "caller-token" } }, {}, err => { + await checkAccessToken({ headers: { authorization: "Bearer caller-token" } }, {}, err => { nextError = err }) @@ -188,7 +188,7 @@ REFRESH_TOKEN=refresh-token`) } let nextError - await checkAccessToken({ headers: { authorization: "caller-token" } }, {}, err => { + await checkAccessToken({ headers: { authorization: "Bearer caller-token" } }, {}, err => { nextError = err }) diff --git a/test/routes/update.test.js b/test/routes/update.test.js index a7013b7..09d52e2 100644 --- a/test/routes/update.test.js +++ b/test/routes/update.test.js @@ -207,7 +207,7 @@ describe("Check that the update route passes caller tokens through to RERUM. __ let response = await request(routeTester) .put("/update") .set("Content-Type", "application/json") - .set("Authorization", "caller-token") + .set("Authorization", "Bearer bad-or-expired-token") .send({ "@id": rerumUriOrig, testing: "item" }) assert.equal(response.statusCode, 401) @@ -223,7 +223,7 @@ describe("Check that the update route passes caller tokens through to RERUM. __ response = await request(routeTester) .put("/update") .set("Content-Type", "application/json") - .set("Authorization", "caller-token") + .set("Authorization", "Bearer bad-or-expired-token") .send({ "@id": rerumUriOrig, testing: "item" }) assert.equal(response.statusCode, 403) From 7d3311a327ec52ca4fcb11950d486b3657566afe Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 30 Sep 2026 10:13:53 -0500 Subject: [PATCH 7/9] Make sure the test doesn't get tricked by the kill switch --- test/helpers/env.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/helpers/env.js b/test/helpers/env.js index 09f7f0d..a82f0b7 100644 --- a/test/helpers/env.js +++ b/test/helpers/env.js @@ -5,3 +5,6 @@ process.env.RERUM_ID_PATTERN ??= "https://devstore.rerum.io/v1/id/" process.env.ORIGIN ??= "http://localhost" process.env.ACCESS_TOKEN ??= "token" process.env.OPEN_API_CORS ??= "false" +// Passthrough tests assume the default. Pin it so a deploy box's .env with +// ALLOW_PASSTHROUGH_TOKENS=false cannot flip the suite (dotenv won't override this). +process.env.ALLOW_PASSTHROUGH_TOKENS = "true" From 9922a319396efe3600c9187071fbcbac49025514 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 30 Sep 2026 10:58:44 -0500 Subject: [PATCH 8/9] cleanup, ready for merge --- .../components/tinynode-shared-components.openapi.yaml | 9 +-------- routes/create.js | 2 -- routes/delete.js | 4 ---- routes/overwrite.js | 2 -- routes/update.js | 2 -- 5 files changed, 1 insertion(+), 18 deletions(-) diff --git a/openapi/components/tinynode-shared-components.openapi.yaml b/openapi/components/tinynode-shared-components.openapi.yaml index 82902a1..0ee93b2 100644 --- a/openapi/components/tinynode-shared-components.openapi.yaml +++ b/openapi/components/tinynode-shared-components.openapi.yaml @@ -22,14 +22,7 @@ components: Caller-supplied RERUM access token. When present on a modification request, TinyNode forwards it upstream verbatim and the write is attributed to the caller's registered agent instead of the instance's - agent. TinyNode does not validate the token; RERUM rejects invalid - tokens with 401, and that 401 (or 403) is passed back to the caller - with its real status code so the caller can debug their own token. For - requests without this header, RERUM's 401/403 is reported as a 502 - whose body starts with "401:" or "403:" and includes RERUM's message, - the same as any other upstream RERUM error. Operators can reject - passthrough with ALLOW_PASSTHROUGH_TOKENS=false, which answers 403 for - any modification request carrying this header. + agent. schemas: HealthResponse: type: object diff --git a/routes/create.js b/routes/create.js index 7f8da03..d1e3a9b 100644 --- a/routes/create.js +++ b/routes/create.js @@ -30,8 +30,6 @@ router.post('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessTo const rerumResponse = await fetchRerum(createURL, createOptions) .then(async (resp) => { if (resp.ok) return resp.json() - // For actual passthrough requests, let callers see RERUM's 401/403 directly - // so they can debug their own tokens. Otherwise keep TinyNode's 502 contract. if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { let rerumAuthMessage try { diff --git a/routes/delete.js b/routes/delete.js index c9e3ff6..efd70aa 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -29,8 +29,6 @@ router.delete('/', verifyJsonContentType, requirePassthroughAllowed, checkAccess await fetchRerum(deleteURL, deleteOptions) .then(async (resp) => { if (resp.ok) return - // For actual passthrough requests, let callers see RERUM's 401/403 directly - // so they can debug their own tokens. Otherwise keep TinyNode's 502 contract. if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { let rerumAuthMessage try { @@ -74,8 +72,6 @@ router.delete('/:id', requirePassthroughAllowed, checkAccessToken, async (req, r await fetchRerum(deleteURL, deleteOptions) .then(async (resp) => { if (resp.ok) return - // For actual passthrough requests, let callers see RERUM's 401/403 directly - // so they can debug their own tokens. Otherwise keep TinyNode's 502 contract. if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { let rerumAuthMessage try { diff --git a/routes/overwrite.js b/routes/overwrite.js index 51307a3..f02b567 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -51,8 +51,6 @@ router.put('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessTok err.body = conflictBody throw err } - // For actual passthrough requests, let callers see RERUM's 401/403 directly - // so they can debug their own tokens. Otherwise keep TinyNode's 502 contract. if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { let rerumAuthMessage try { diff --git a/routes/update.js b/routes/update.js index a0994e8..f1239cb 100644 --- a/routes/update.js +++ b/routes/update.js @@ -29,8 +29,6 @@ router.put('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessTok const rerumResponse = await fetchRerum(updateURL, updateOptions) .then(async (resp) => { if (resp.ok) return resp.json() - // For actual passthrough requests, let callers see RERUM's 401/403 directly - // so they can debug their own tokens. Otherwise keep TinyNode's 502 contract. if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { let rerumAuthMessage try { From 90238426c7505960b47ad25a06823fa24816f6f8 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 30 Sep 2026 11:15:19 -0500 Subject: [PATCH 9/9] get rid of unnecessary tests --- test/routes/openapi_sync_artifacts.test.js | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/test/routes/openapi_sync_artifacts.test.js b/test/routes/openapi_sync_artifacts.test.js index dec6c24..560f02f 100644 --- a/test/routes/openapi_sync_artifacts.test.js +++ b/test/routes/openapi_sync_artifacts.test.js @@ -20,26 +20,6 @@ describe("Shared OpenAPI artifact sync scaffolding.", () => { assert.match(artifact, /^\s+schemas:/m, "artifact must define components.schemas") }) - it("the canonical shared artifact exposes the bearer passthrough security scheme. __exists __core", () => { - const artifact = fs.readFileSync(artifactPath, "utf8") - assert.match(artifact, /^\s+securitySchemes:/m, "artifact must define components.securitySchemes") - assert.match( - artifact, - /^ bearerAuth:/m, - "artifact must define the bearerAuth security scheme named by TinyNode's passthrough mode" - ) - assert.match( - artifact, - /bearerAuth:\s*\n\s+type: http\s*\n\s+scheme: bearer/, - "bearerAuth must be an HTTP bearer scheme so consumers can send caller tokens via the Authorization header" - ) - assert.match( - artifact, - /ALLOW_PASSTHROUGH_TOKENS/, - "bearerAuth description must mention the operator kill switch" - ) - }) - it("the sync workflow targets the correct receiver repo and paths. __exists __core", () => { const workflow = fs.readFileSync(workflowPath, "utf8") assert.match(workflow, /repository:\s*cubap\/rerum_openapi/, "workflow must check out cubap/rerum_openapi as the receiver")