From 4b4ff16c3820a12d57872d80ea465b603370bae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= <16311043+immakermatty@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:08:24 +0000 Subject: [PATCH 1/5] =?UTF-8?q?module-kit=200.3.0:=20signedInPerson=20?= =?UTF-8?q?=E2=80=94=20who=20is=20signed=20in=20to=20Lazurio=20for=20a=20r?= =?UTF-8?q?equest=20(Lazurio/LazurioPlatform#474)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads X-Auth-Request-User (subject) and X-Auth-Request-Preferred-Username (username), which the Environment's gateway sets from its own admission answer, only behind the gateway: the listener has an external origin and the request's Host is its host. Everywhere else it is unavailable with a reason (not-hosted, not-through-gateway, no-person). Python gets signed_in_person with the same rules (lazurio-module-kit 0.2.0). Co-Authored-By: Claude Opus 5.5 --- README.md | 57 +++++++++- package.json | 4 +- python/pyproject.toml | 4 +- python/src/lazurio_module_kit/__init__.py | 74 +++++++++++++ python/tests/test_module_kit.py | 40 ++++++++ python/uv.lock | 4 +- src/index.ts | 86 ++++++++++++++++ test/index.test.ts | 120 ++++++++++++++++++++++ 8 files changed, 379 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 11b8917..ea2c1d6 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # @lazurio/module-kit Runtime helpers for Apps that follow the **Lazurio Module Standard**: read the -listener a Lazurio Platform assigned, answer health, and shut down cleanly on -`SIGTERM`. +listener a Lazurio Platform assigned, answer health, shut down cleanly on +`SIGTERM`, and read who is signed in to Lazurio for a request. This is the **only shared runtime library of a Module**. It stays small on purpose: reading the runtime environment, health, shutdown and dev server @@ -12,7 +12,7 @@ beyond that belongs in the Module itself. ## Install ```sh -bun add github:Lazurio/module-kit#v0.2.0 +bun add github:Lazurio/module-kit#v0.3.0 ``` The package ships TypeScript sources (`exports` and `types` point at @@ -61,9 +61,58 @@ try { | `onShutdown(fn, { timeoutMs = 10_000 })` | on `SIGTERM`/`SIGINT`, runs `fn` once and exits 0; exits 1 if `fn` throws or does not finish within `timeoutMs`; returns a function that removes the handlers | | `requireEnvironment(names, env)` | the values as a record; throws `environment-missing` naming every absent or empty variable | +| `signedInPerson(request, id = "app", env)` | who is signed in to Lazurio for this request: `{ status: "signed-in", subject, username }` or `{ status: "unavailable", reason }`, see [Who is signed in](#who-is-signed-in) | +| `isLazurioUsername(value)` | whether a value is a Lazurio username (3–39 lowercase letters and digits, single dashes between them, not UUID-shaped, not a service account) | + `ModuleKitError.code` is one of `listener-missing`, `listeners-invalid` or `environment-missing`. +## Who is signed in + +On a Remote Environment the Environment's gateway admits every request to a +Module with the person's Lazurio session and then sets two request headers +from its own admission answer: `X-Auth-Request-User`, the Lazurio account's +subject (a lowercase UUID), and `X-Auth-Request-Preferred-Username`, the +person's Lazurio username. Every copy a client sends is removed first. The +contract is the Module Standard's "Who is signed in" in Lazurio Platform +(`docs/module-standard.md`). + +```ts +import { signedInPerson } from "@lazurio/module-kit"; + +// inside Bun.serve fetch(request) +const person = signedInPerson(request, "app"); +if (person.status === "signed-in") + return Response.json({ subject: person.subject, name: person.username }); +return Response.json({ unavailable: person.reason }); +``` + +- **Only behind the gateway.** The headers count only when the listener has an + external origin (`LAZURIO_RUNTIME_LISTENER__EXTERNAL_ORIGIN`, which the + Platform passes on a Remote Environment) and the request's `Host` is that + origin's host. On a person's computer the result is always + `unavailable: not-hosted`, whatever the request carries; a request from a + sibling or a script on the Environment is `not-through-gateway`; the + Environment browser's local entry and a gateway without this capability give + `no-person`. Show these honestly as "unavailable", never as a guessed name. +- **Per request.** Read it for each request and never cache it: after another + account signs in, the next request names the new person, and an expired + session never reaches the Module. +- **Remember a person by `subject`**, show `username`. `username` is `null` + when the gateway named none. +- **Read-only.** It is identity for display and the Module's own rules: not a + role, a grant, a key to data or a signature. Do not forward the headers to a + sibling or another service. +- **Its boundary is the Environment.** Any process on the Environment that + reaches the Module's loopback port could send the headers itself, like it + could read the Module's data. Where identity must hold against the + Environment's operator, it needs an identity signed by Lazurio Auth, which + this helper does not provide. + +Python: `signed_in_person(headers, "app")` takes the request headers as a +mapping (matched case-insensitively; a repeated header reads as none) and +returns `SignedIn(subject, username)` or `Unavailable(reason)`. + ## Bun.serve ```ts @@ -123,7 +172,7 @@ The `python/` directory holds `lazurio-module-kit`, the same `listener` and `health` semantics for a Python Module (`uv` + `pyproject.toml`): ```sh -uv add "lazurio-module-kit @ git+https://github.com/Lazurio/module-kit@v0.1.0#subdirectory=python" +uv add "lazurio-module-kit @ git+https://github.com/Lazurio/module-kit@v0.3.0#subdirectory=python" ``` ```python diff --git a/package.json b/package.json index b786e78..1025612 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@lazurio/module-kit", - "version": "0.2.0", - "description": "Lazurio Module Standard runtime helpers: listener, health, shutdown", + "version": "0.3.0", + "description": "Lazurio Module Standard runtime helpers: listener, health, shutdown, signed-in person", "license": "MIT", "type": "module", "packageManager": "bun@1.4.2", diff --git a/python/pyproject.toml b/python/pyproject.toml index 8638ba6..1680c35 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "lazurio-module-kit" -version = "0.1.0" -description = "Lazurio Module Standard runtime helpers: listener, health" +version = "0.2.0" +description = "Lazurio Module Standard runtime helpers: listener, health, signed-in person" license = "MIT" requires-python = ">=3.11" dependencies = [] diff --git a/python/src/lazurio_module_kit/__init__.py b/python/src/lazurio_module_kit/__init__.py index 8af20d1..ae295ed 100644 --- a/python/src/lazurio_module_kit/__init__.py +++ b/python/src/lazurio_module_kit/__init__.py @@ -17,12 +17,18 @@ from urllib.parse import urlsplit __all__ = [ + "SIGNED_IN_SUBJECT_HEADER", + "SIGNED_IN_USERNAME_HEADER", "HealthResponse", "Listener", "ModuleKitError", + "SignedIn", + "Unavailable", "health", + "is_lazurio_username", "listener", "listener_variable_key", + "signed_in_person", ] ErrorCode = Literal["listener-missing", "listeners-invalid", "environment-missing"] @@ -127,3 +133,71 @@ async def check() -> HealthResponse: return HealthResponse(200 if ok else 503, body, "application/json") return check + + +SIGNED_IN_SUBJECT_HEADER = "x-auth-request-user" +SIGNED_IN_USERNAME_HEADER = "x-auth-request-preferred-username" +_SUBJECT = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + + +@dataclass(frozen=True) +class SignedIn: + """The person signed in to Lazurio for one request: the account's subject + (a lowercase UUID, the key to remember them by) and the Lazurio username + to show, or ``None``. Read-only identity, never a role, a grant, a key to + data or a signature.""" + + subject: str + username: str | None + status: Literal["signed-in"] = "signed-in" + + +@dataclass(frozen=True) +class Unavailable: + """Why nobody is named: ``not-hosted`` (no gateway in front of this + listener), ``not-through-gateway`` (the request's Host is not the + listener's external origin) or ``no-person`` (the gateway named nobody).""" + + reason: Literal["not-hosted", "not-through-gateway", "no-person"] + status: Literal["unavailable"] = "unavailable" + + +def is_lazurio_username(value: object) -> bool: + """3 to 39 lowercase letters and digits with single dashes between them, + never shaped like a UUID and never a service account's.""" + return ( + isinstance(value, str) + and 3 <= len(value) <= 39 + and re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", value) is not None + and _SUBJECT.fullmatch(value.lower()) is None + and not value.startswith("service-account-") + ) + + +def _header(headers: Mapping[str, str], name: str) -> str | None: + """One header, matched case-insensitively; a repeated one reads as none.""" + values = [value for key, value in headers.items() if key.lower() == name] + return values[0] if len(values) == 1 else None + + +def signed_in_person( + headers: Mapping[str, str], + listener_id: str = "app", + env: Mapping[str, str] | None = None, +) -> SignedIn | Unavailable: + """Who is signed in to Lazurio for this request, from the headers the + Environment's gateway sets from its own admission answer; the same rules + as ``signedInPerson`` of the TypeScript package. The headers count only + when the listener has an external origin and the request's Host is that + origin's host. Read it per request, never cache it. Raises + ``listener-missing`` like ``listener``.""" + origin = listener(listener_id, env).external_origin + if origin is None: + return Unavailable("not-hosted") + if _header(headers, "host") != urlsplit(origin).netloc: + return Unavailable("not-through-gateway") + subject = _header(headers, SIGNED_IN_SUBJECT_HEADER) + if subject is None or _SUBJECT.fullmatch(subject) is None: + return Unavailable("no-person") + username = _header(headers, SIGNED_IN_USERNAME_HEADER) + return SignedIn(subject, username if is_lazurio_username(username) else None) diff --git a/python/tests/test_module_kit.py b/python/tests/test_module_kit.py index 05404d3..7f8d4cb 100644 --- a/python/tests/test_module_kit.py +++ b/python/tests/test_module_kit.py @@ -7,9 +7,12 @@ HealthResponse, Listener, ModuleKitError, + SignedIn, + Unavailable, health, listener, listener_variable_key, + signed_in_person, ) APP = { @@ -101,3 +104,40 @@ def broken() -> bool: raise RuntimeError("db down") assert _run(broken).status == 503 + + +HOST = "deals.vm-01.example.lazurio.io" +HOSTED = {**APP, "LAZURIO_RUNTIME_LISTENER_APP_EXTERNAL_ORIGIN": f"https://{HOST}"} +ALENA = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" + + +def test_signed_in_behind_the_gateway() -> None: + headers = {"Host": HOST, "X-Auth-Request-User": ALENA, "X-Auth-Request-Preferred-Username": "alena"} + assert signed_in_person(headers, "app", HOSTED) == SignedIn(ALENA, "alena") + + +def test_not_hosted_ignores_every_header() -> None: + headers = {"host": "127.0.0.1:43100", "x-auth-request-user": ALENA} + assert signed_in_person(headers, "app", APP) == Unavailable("not-hosted") + + +def test_not_through_the_gateway() -> None: + headers = {"host": "127.0.0.1:43100", "x-auth-request-user": ALENA} + assert signed_in_person(headers, "app", HOSTED) == Unavailable("not-through-gateway") + + +@pytest.mark.parametrize("subject", [None, "", ALENA.upper(), ALENA + "0", "alena"]) +def test_no_person(subject: str | None) -> None: + headers = {"host": HOST} if subject is None else {"host": HOST, "x-auth-request-user": subject} + assert signed_in_person(headers, "app", HOSTED) == Unavailable("no-person") + + +def test_repeated_subject_is_nobody() -> None: + headers = {"host": HOST, "x-auth-request-user": ALENA, "X-Auth-Request-User": ALENA} + assert signed_in_person(headers, "app", HOSTED) == Unavailable("no-person") + + +@pytest.mark.parametrize("username", ["Alena", "al", "a" * 40, "-alena", ALENA, "service-account-pilot"]) +def test_invalid_username_is_none(username: str) -> None: + headers = {"host": HOST, "x-auth-request-user": ALENA, "x-auth-request-preferred-username": username} + assert signed_in_person(headers, "app", HOSTED) == SignedIn(ALENA, None) diff --git a/python/uv.lock b/python/uv.lock index 5cdae00..88ba742 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" [[package]] @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "lazurio-module-kit" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } [package.dev-dependencies] diff --git a/src/index.ts b/src/index.ts index a16bde8..9ba5926 100644 --- a/src/index.ts +++ b/src/index.ts @@ -338,3 +338,89 @@ export function requireEnvironment( string >; } + +/** Who is signed in to Lazurio for one incoming request, or why that is + * unavailable. Read-only identity for display and the Module's own rules: + * never a role, a grant, a key to data or a signature. */ +export type SignedInPerson = + | Readonly<{ + status: "signed-in"; + /** The Lazurio account's subject, a lowercase UUID: stable for the + * account, the key to remember a person by. */ + subject: string; + /** The person's Lazurio username, to show; null when the gateway's + * answer named none. */ + username: string | null; + }> + | Readonly<{ + status: "unavailable"; + /** `not-hosted`: no gateway in front of this listener (a person's + * computer, or an Environment without one), so no request names + * anyone. `not-through-gateway`: the request's `Host` is not the + * listener's external origin (a sibling, a script on the Environment). + * `no-person`: the gateway named nobody (the Environment browser's + * local entry, a gateway without this capability). */ + reason: "not-hosted" | "not-through-gateway" | "no-person"; + }>; + +/** What `signedInPerson` reads of a request: a `Request` or its headers. */ +export type RequestHeaders = Readonly<{ get(name: string): string | null }>; + +/** The headers the Environment's gateway sets after it admits a request + * (Lazurio Module Standard, "Who is signed in"). */ +export const SIGNED_IN_SUBJECT_HEADER = "x-auth-request-user"; +export const SIGNED_IN_USERNAME_HEADER = "x-auth-request-preferred-username"; + +const SUBJECT = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const UUID_ANY_CASE = new RegExp(SUBJECT.source, "i"); + +/** A Lazurio username: 3 to 39 lowercase letters and digits with single + * dashes between them, never shaped like a UUID and never a service + * account's (`service-account-…`). */ +export function isLazurioUsername(value: unknown): value is string { + return ( + typeof value === "string" && + value.length >= 3 && + value.length <= 39 && + /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && + !UUID_ANY_CASE.test(value) && + !value.startsWith("service-account-") + ); +} + +/** Who is signed in to Lazurio for this request, read from the headers the + * Environment's gateway sets from its own admission answer. + * + * The headers count only behind the gateway: when the listener has an + * external origin (`LAZURIO_RUNTIME_LISTENER__EXTERNAL_ORIGIN`, a hosted + * entrypoint) and the request's `Host` is that origin's host. Everywhere + * else the result is `unavailable` whatever the request carries, because + * there a header could come from anyone. The gateway removes every client + * copy and admits every request again, so read it per request and never + * cache it: after another account signs in, the next request names the new + * person, and an expired session never reaches the Module. A repeated header + * or a value of another form reads as nobody. Throws `listener-missing` like + * `listener(id)`. */ +export function signedInPerson( + request: Request | RequestHeaders, + id = "app", + env: Environment = process.env, +): SignedInPerson { + const { externalOrigin } = listener(id, env); + if (externalOrigin === null) + return { status: "unavailable", reason: "not-hosted" }; + const headers: RequestHeaders = + "headers" in request ? request.headers : request; + if (headers.get("host") !== new URL(externalOrigin).host) + return { status: "unavailable", reason: "not-through-gateway" }; + const subject = headers.get(SIGNED_IN_SUBJECT_HEADER); + if (subject === null || !SUBJECT.test(subject)) + return { status: "unavailable", reason: "no-person" }; + const username = headers.get(SIGNED_IN_USERNAME_HEADER); + return { + status: "signed-in", + subject, + username: isLazurioUsername(username) ? username : null, + }; +} diff --git a/test/index.test.ts b/test/index.test.ts index f6b4dd8..bdce230 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -4,6 +4,7 @@ import { allowedHosts, astroServerOptions, health, + isLazurioUsername, listener, listeners, listenerVariableKey, @@ -12,6 +13,7 @@ import { requireEnvironment, runtimeIdentity, type ShutdownProcess, + signedInPerson, viteServerOptions, withHealth, } from "../src/index.ts"; @@ -491,3 +493,121 @@ test("viteShutdownPlugin exits 0 as soon as close() resolves", async () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(fake.exits).toEqual([0]); }); + +describe("signedInPerson", () => { + const HOST = "deals.vm-01.example.lazurio.io"; + const ALENA = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; + const through = (extra: Record = {}) => + new Request(`https://${HOST}/api/me`, { + headers: { host: HOST, ...extra }, + }); + + test("behind the gateway: the subject and username it set", () => { + expect( + signedInPerson( + through({ + "X-Auth-Request-User": ALENA, + "X-Auth-Request-Preferred-Username": "alena", + }), + "app", + HOSTED, + ), + ).toEqual({ status: "signed-in", subject: ALENA, username: "alena" }); + }); + + test("reads plain headers too (any server)", () => { + const headers = new Headers({ host: HOST, "x-auth-request-user": ALENA }); + expect(signedInPerson(headers, "app", HOSTED)).toEqual({ + status: "signed-in", + subject: ALENA, + username: null, + }); + }); + + test("without a gateway no header names anyone", () => { + const forged = new Request("http://127.0.0.1:43100/", { + headers: { + host: "127.0.0.1:43100", + "x-auth-request-user": ALENA, + "x-auth-request-preferred-username": "alena", + }, + }); + expect(signedInPerson(forged, "app", APP)).toEqual({ + status: "unavailable", + reason: "not-hosted", + }); + }); + + test("a request that did not come through the gateway is nobody", () => { + for (const host of ["127.0.0.1:43100", "notes.vm-01.example.lazurio.io"]) + expect( + signedInPerson( + new Request(`http://${host}/`, { + headers: { host, "x-auth-request-user": ALENA }, + }), + "app", + HOSTED, + ), + ).toEqual({ status: "unavailable", reason: "not-through-gateway" }); + }); + + test("no subject, a malformed one or a repeated header is nobody", () => { + for (const subject of [ + undefined, + "", + ALENA.toUpperCase(), + `${ALENA}0`, + "alena", + `${ALENA}, 9a8b7c6d-5e4f-4321-8fed-cba987654321`, + ]) + expect( + signedInPerson( + through( + subject === undefined ? {} : { "x-auth-request-user": subject }, + ), + "app", + HOSTED, + ), + ).toEqual({ status: "unavailable", reason: "no-person" }); + }); + + test("a username outside the Lazurio rule is no username", () => { + for (const username of [ + "Alena", + "al", + "a".repeat(40), + "-alena", + "ale--na", + ALENA, + "service-account-pilot", + "alena, bob", + ]) + expect( + signedInPerson( + through({ + "x-auth-request-user": ALENA, + "x-auth-request-preferred-username": username, + }), + "app", + HOSTED, + ), + ).toEqual({ status: "signed-in", subject: ALENA, username: null }); + expect(isLazurioUsername("bob-example")).toBe(true); + }); + + test("per request: another account on the next request is that person", () => { + const bob = "9a8b7c6d-5e4f-4321-8fed-cba987654321"; + expect( + signedInPerson(through({ "x-auth-request-user": ALENA }), "app", HOSTED), + ).toMatchObject({ subject: ALENA }); + expect( + signedInPerson(through({ "x-auth-request-user": bob }), "app", HOSTED), + ).toMatchObject({ subject: bob }); + }); + + test("an App started outside Lazurio fails like listener()", () => { + expect(thrown(() => signedInPerson(through(), "app", {})).code).toBe( + "listener-missing", + ); + }); +}); From 74d07b6d3ca1cb8e1ba9d7eb49a60463537768f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= <16311043+immakermatty@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:43:59 +0000 Subject: [PATCH 2/5] module-kit 0.3.0: appAccess, who came to the App and with which application role (decision 0231) Replaces the unreleased signedInPerson with the stable contract both threads agreed on: entry (environment, app, local, unknown), appRole (admin, user, none with unknown), person (subject, username) and source (gateway, later auth). A request from inside the Environment is local and an application admin without a name; a request through the gateway without a valid statement is unknown and has no role. The headers are internal transport, exported as GATEWAY_HEADERS for a Module's own tests. Python gets app_access with the same rules. Co-Authored-By: Claude Opus 5.5 --- README.md | 96 ++++++------ package.json | 2 +- python/pyproject.toml | 2 +- python/src/lazurio_module_kit/__init__.py | 100 +++++++++---- python/tests/test_module_kit.py | 97 +++++++++--- src/index.ts | 162 ++++++++++++++------ test/index.test.ts | 174 ++++++++++++++-------- 7 files changed, 426 insertions(+), 207 deletions(-) diff --git a/README.md b/README.md index ea2c1d6..dbba1d2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Runtime helpers for Apps that follow the **Lazurio Module Standard**: read the listener a Lazurio Platform assigned, answer health, shut down cleanly on -`SIGTERM`, and read who is signed in to Lazurio for a request. +`SIGTERM`, and learn who came to the App and with which application role. This is the **only shared runtime library of a Module**. It stays small on purpose: reading the runtime environment, health, shutdown and dev server @@ -61,57 +61,65 @@ try { | `onShutdown(fn, { timeoutMs = 10_000 })` | on `SIGTERM`/`SIGINT`, runs `fn` once and exits 0; exits 1 if `fn` throws or does not finish within `timeoutMs`; returns a function that removes the handlers | | `requireEnvironment(names, env)` | the values as a record; throws `environment-missing` naming every absent or empty variable | -| `signedInPerson(request, id = "app", env)` | who is signed in to Lazurio for this request: `{ status: "signed-in", subject, username }` or `{ status: "unavailable", reason }`, see [Who is signed in](#who-is-signed-in) | +| `appAccess(request, id = "app", env)` | who came to the App for this request and with which application role: `{ entry, appRole, person, source }` or, without a person, `{ entry, appRole, person: null, reason }`, see [Who came and with which role](#who-came-and-with-which-role) | | `isLazurioUsername(value)` | whether a value is a Lazurio username (3–39 lowercase letters and digits, single dashes between them, not UUID-shaped, not a service account) | `ModuleKitError.code` is one of `listener-missing`, `listeners-invalid` or `environment-missing`. -## Who is signed in +## Who came and with which role -On a Remote Environment the Environment's gateway admits every request to a -Module with the person's Lazurio session and then sets two request headers -from its own admission answer: `X-Auth-Request-User`, the Lazurio account's -subject (a lowercase UUID), and `X-Auth-Request-Preferred-Username`, the -person's Lazurio username. Every copy a client sends is removed first. The -contract is the Module Standard's "Who is signed in" in Lazurio Platform -(`docs/module-standard.md`). +Lazurio decides who may open a Module's App and with which application role, +application admin or application user, and the Organization decides in the +Module's code what each role may do (Lazurio decision 0231). `appAccess` is the +only way a Module learns it, and its answer is the stable contract: its fields +and values never change meaning or disappear, and new information comes as new +fields. The contract is the Module Standard's "Who came and with which role" +in Lazurio Platform (`docs/module-standard.md`). ```ts -import { signedInPerson } from "@lazurio/module-kit"; - -// inside Bun.serve fetch(request) -const person = signedInPerson(request, "app"); -if (person.status === "signed-in") - return Response.json({ subject: person.subject, name: person.username }); -return Response.json({ unavailable: person.reason }); +import { appAccess } from "@lazurio/module-kit"; + +// inside Bun.serve fetch(request), on every request, including APIs +const access = appAccess(request, "app"); +if (access.appRole === null) + return new Response("Lazurio did not say who you are.", { status: 403 }); +const mayApprove = access.appRole === "admin"; // the Organization's own rule +const author = access.person?.subject ?? null; // null: from inside the Environment ``` -- **Only behind the gateway.** The headers count only when the listener has an - external origin (`LAZURIO_RUNTIME_LISTENER__EXTERNAL_ORIGIN`, which the - Platform passes on a Remote Environment) and the request's `Host` is that - origin's host. On a person's computer the result is always - `unavailable: not-hosted`, whatever the request carries; a request from a - sibling or a script on the Environment is `not-through-gateway`; the - Environment browser's local entry and a gateway without this capability give - `no-person`. Show these honestly as "unavailable", never as a guessed name. -- **Per request.** Read it for each request and never cache it: after another - account signs in, the next request names the new person, and an expired - session never reaches the Module. -- **Remember a person by `subject`**, show `username`. `username` is `null` - when the gateway named none. -- **Read-only.** It is identity for display and the Module's own rules: not a - role, a grant, a key to data or a signature. Do not forward the headers to a - sibling or another service. -- **Its boundary is the Environment.** Any process on the Environment that - reaches the Module's loopback port could send the headers itself, like it - could read the Module's data. Where identity must hold against the - Environment's operator, it needs an identity signed by Lazurio Auth, which - this helper does not provide. - -Python: `signed_in_person(headers, "app")` takes the request headers as a -mapping (matched case-insensitively; a repeated header reads as none) and -returns `SignedIn(subject, username)` or `Unavailable(reason)`. +| `entry` | `appRole` | `person` | When | +| --- | --- | --- | --- | +| `environment` | `admin` | subject and username | the person has the whole Environment where the App runs | +| `app` | `admin` or `user` | subject and username | the person has this App only, with the role the Organization gave them | +| `local` | `admin` | `null` | a request from inside the Environment: a person's own computer (`reason: "not-hosted"`), a sibling or a script on the Environment (`not-through-gateway`), the Environment browser's local entry (`local-entry`) | +| `unknown` | `null` | `null` | through the gateway without identity (`no-identity`), for example a gateway release without this capability: no role, show "unavailable" | + +`source` is `gateway` today: the Environment's gateway states it from its own +admission answer after removing every copy a client sent. An identity signed by +Lazurio Auth for use outside the Environment will come as `source: "auth"`. + +- **Enforce on the server, per request.** Check the role for every request, + including APIs, downloads, WebSockets and direct URLs, never only by hiding a + button, and never cache it: after another account signs in, the next request + names the new person. +- **Remember a person by `subject`**, show `username` (`null` when the gateway + named none). Never fill in a name from the browser, the operating system + account or the Environment's assignee. +- **Finer roles are the Module's.** Approvers, technicians and the like live in + the Module's own data, keyed by `subject`. Lazurio keeps only the two roles. +- **Its boundary is the Environment.** A process on the Environment that reaches + the App's loopback port could send anything, as it could read the App's data. + The statement holds against visitors who have only the App, not against the + Environment's operator. Do not forward it to a sibling or another service. +- **Transport is not the contract.** `GATEWAY_HEADERS` names the headers the + gateway uses today so that a Module's own tests can build requests; read them + only through `appAccess`. + +Python: `app_access(headers, "app")` takes the request headers as a mapping +(matched case-insensitively; a repeated header reads as none) and returns +`AppAccess(entry, app_role, person, source, reason)` with +`AppPerson(subject, username)` or `None`, by the same rules. ## Bun.serve @@ -168,8 +176,8 @@ export default defineConfig({ ## Python -The `python/` directory holds `lazurio-module-kit`, the same `listener` and -`health` semantics for a Python Module (`uv` + `pyproject.toml`): +The `python/` directory holds `lazurio-module-kit`, the same `listener`, +`health` and `app_access` semantics for a Python Module (`uv` + `pyproject.toml`): ```sh uv add "lazurio-module-kit @ git+https://github.com/Lazurio/module-kit@v0.3.0#subdirectory=python" diff --git a/package.json b/package.json index 1025612..c88b332 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@lazurio/module-kit", "version": "0.3.0", - "description": "Lazurio Module Standard runtime helpers: listener, health, shutdown, signed-in person", + "description": "Lazurio Module Standard runtime helpers: listener, health, shutdown, app access", "license": "MIT", "type": "module", "packageManager": "bun@1.4.2", diff --git a/python/pyproject.toml b/python/pyproject.toml index 1680c35..2322726 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "lazurio-module-kit" version = "0.2.0" -description = "Lazurio Module Standard runtime helpers: listener, health, signed-in person" +description = "Lazurio Module Standard runtime helpers: listener, health, app access" license = "MIT" requires-python = ">=3.11" dependencies = [] diff --git a/python/src/lazurio_module_kit/__init__.py b/python/src/lazurio_module_kit/__init__.py index ae295ed..02c06b2 100644 --- a/python/src/lazurio_module_kit/__init__.py +++ b/python/src/lazurio_module_kit/__init__.py @@ -13,22 +13,22 @@ import re from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass +from types import MappingProxyType from typing import Literal, NamedTuple from urllib.parse import urlsplit __all__ = [ - "SIGNED_IN_SUBJECT_HEADER", - "SIGNED_IN_USERNAME_HEADER", + "GATEWAY_HEADERS", + "AppAccess", + "AppPerson", "HealthResponse", "Listener", "ModuleKitError", - "SignedIn", - "Unavailable", + "app_access", "health", "is_lazurio_username", "listener", "listener_variable_key", - "signed_in_person", ] ErrorCode = Literal["listener-missing", "listeners-invalid", "environment-missing"] @@ -135,31 +135,53 @@ async def check() -> HealthResponse: return check -SIGNED_IN_SUBJECT_HEADER = "x-auth-request-user" -SIGNED_IN_USERNAME_HEADER = "x-auth-request-preferred-username" +GATEWAY_HEADERS: Mapping[str, str] = MappingProxyType( + { + "subject": "x-auth-request-user", + "username": "x-auth-request-preferred-username", + "entry": "x-lazurio-entry", + "app_role": "x-lazurio-app-role", + } +) +"""How the Environment's gateway carries its statement to the App. Internal +transport, not a contract: only ``app_access`` reads it. Exported for a +Module's own tests.""" + _SUBJECT = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") +Entry = Literal["environment", "app", "local", "unknown"] +AppRole = Literal["admin", "user"] +Source = Literal["gateway", "auth"] +Reason = Literal["not-hosted", "not-through-gateway", "local-entry", "no-identity"] + @dataclass(frozen=True) -class SignedIn: - """The person signed in to Lazurio for one request: the account's subject - (a lowercase UUID, the key to remember them by) and the Lazurio username - to show, or ``None``. Read-only identity, never a role, a grant, a key to - data or a signature.""" +class AppPerson: + """A person the Environment's gateway admitted: the account's subject (a + lowercase UUID, the key to remember them by) and the Lazurio username to + show, or ``None``.""" subject: str username: str | None - status: Literal["signed-in"] = "signed-in" @dataclass(frozen=True) -class Unavailable: - """Why nobody is named: ``not-hosted`` (no gateway in front of this - listener), ``not-through-gateway`` (the request's Host is not the - listener's external origin) or ``no-person`` (the gateway named nobody).""" +class AppAccess: + """Who came to this App and with which application role (decision 0231); + the same contract as ``appAccess`` of the TypeScript package. + + ``entry`` is ``environment`` (the whole Environment, so ``app_role`` is + ``admin``), ``app`` (this App only, with the role the Organization gave), + ``local`` (a request from inside the Environment: an application admin + without a name) or ``unknown`` (through the gateway without identity: no + ``app_role``). ``source`` is set with a person, ``reason`` without one. + Fields and values never change meaning or disappear.""" - reason: Literal["not-hosted", "not-through-gateway", "no-person"] - status: Literal["unavailable"] = "unavailable" + entry: Entry + app_role: AppRole | None + person: AppPerson | None + source: Source | None = None + reason: Reason | None = None def is_lazurio_username(value: object) -> bool: @@ -180,24 +202,38 @@ def _header(headers: Mapping[str, str], name: str) -> str | None: return values[0] if len(values) == 1 else None -def signed_in_person( +def app_access( headers: Mapping[str, str], listener_id: str = "app", env: Mapping[str, str] | None = None, -) -> SignedIn | Unavailable: - """Who is signed in to Lazurio for this request, from the headers the - Environment's gateway sets from its own admission answer; the same rules - as ``signedInPerson`` of the TypeScript package. The headers count only - when the listener has an external origin and the request's Host is that - origin's host. Read it per request, never cache it. Raises +) -> AppAccess: + """Who came to this App and with which application role, for one request; + the same rules as ``appAccess`` of the TypeScript package. The gateway's + statement counts only when the listener has an external origin and the + request's Host is that origin's host; any other request is ``local``. + Behind the gateway a missing, repeated or malformed statement is + ``unknown``, never a role. Read it per request, never cache it. Raises ``listener-missing`` like ``listener``.""" origin = listener(listener_id, env).external_origin if origin is None: - return Unavailable("not-hosted") + return AppAccess("local", "admin", None, reason="not-hosted") if _header(headers, "host") != urlsplit(origin).netloc: - return Unavailable("not-through-gateway") - subject = _header(headers, SIGNED_IN_SUBJECT_HEADER) + return AppAccess("local", "admin", None, reason="not-through-gateway") + entry = _header(headers, GATEWAY_HEADERS["entry"]) + subject = _header(headers, GATEWAY_HEADERS["subject"]) + if entry == "local" and subject is None: + return AppAccess("local", "admin", None, reason="local-entry") + unknown = AppAccess("unknown", None, None, reason="no-identity") if subject is None or _SUBJECT.fullmatch(subject) is None: - return Unavailable("no-person") - username = _header(headers, SIGNED_IN_USERNAME_HEADER) - return SignedIn(subject, username if is_lazurio_username(username) else None) + return unknown + username = _header(headers, GATEWAY_HEADERS["username"]) + person = AppPerson(subject, username if is_lazurio_username(username) else None) + if entry == "environment": + return AppAccess("environment", "admin", person, source="gateway") + if entry == "app": + role = _header(headers, GATEWAY_HEADERS["app_role"]) + if role == "admin": + return AppAccess("app", "admin", person, source="gateway") + if role == "user": + return AppAccess("app", "user", person, source="gateway") + return unknown diff --git a/python/tests/test_module_kit.py b/python/tests/test_module_kit.py index 7f8d4cb..152cdeb 100644 --- a/python/tests/test_module_kit.py +++ b/python/tests/test_module_kit.py @@ -1,18 +1,20 @@ import asyncio import json +from typing import Literal import pytest from lazurio_module_kit import ( + GATEWAY_HEADERS, + AppAccess, + AppPerson, HealthResponse, Listener, ModuleKitError, - SignedIn, - Unavailable, + app_access, health, listener, listener_variable_key, - signed_in_person, ) APP = { @@ -80,7 +82,7 @@ def test_reads_process_environment(monkeypatch: pytest.MonkeyPatch) -> None: def _run(ready: object) -> HealthResponse: - return asyncio.run(health(ready)()) # type: ignore[arg-type] + return asyncio.run(health(ready)()) def test_health_ok() -> None: @@ -109,35 +111,86 @@ def broken() -> bool: HOST = "deals.vm-01.example.lazurio.io" HOSTED = {**APP, "LAZURIO_RUNTIME_LISTENER_APP_EXTERNAL_ORIGIN": f"https://{HOST}"} ALENA = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" +UNKNOWN = AppAccess("unknown", None, None, reason="no-identity") -def test_signed_in_behind_the_gateway() -> None: - headers = {"Host": HOST, "X-Auth-Request-User": ALENA, "X-Auth-Request-Preferred-Username": "alena"} - assert signed_in_person(headers, "app", HOSTED) == SignedIn(ALENA, "alena") +def _person(subject: str = ALENA, entry: str = "environment") -> dict[str, str]: + return { + "host": HOST, + GATEWAY_HEADERS["subject"]: subject, + GATEWAY_HEADERS["username"]: "alena", + GATEWAY_HEADERS["entry"]: entry, + } + + +def test_whole_environment_is_an_application_admin() -> None: + headers = { + "Host": HOST, + "X-Auth-Request-User": ALENA, + "X-Auth-Request-Preferred-Username": "alena", + "X-Lazurio-Entry": "environment", + } + assert app_access(headers, "app", HOSTED) == AppAccess( + "environment", "admin", AppPerson(ALENA, "alena"), source="gateway" + ) + + +@pytest.mark.parametrize("role", ["admin", "user"]) +def test_this_app_only_has_the_stated_role(role: Literal["admin", "user"]) -> None: + headers = {**_person(entry="app"), GATEWAY_HEADERS["app_role"]: role} + assert app_access(headers, "app", HOSTED) == AppAccess( + "app", role, AppPerson(ALENA, "alena"), source="gateway" + ) -def test_not_hosted_ignores_every_header() -> None: - headers = {"host": "127.0.0.1:43100", "x-auth-request-user": ALENA} - assert signed_in_person(headers, "app", APP) == Unavailable("not-hosted") +@pytest.mark.parametrize("role", [None, "", "Admin", "owner"]) +def test_this_app_only_without_a_valid_role_is_unknown(role: str | None) -> None: + headers = ( + _person(entry="app") if role is None else {**_person(entry="app"), GATEWAY_HEADERS["app_role"]: role} + ) + assert app_access(headers, "app", HOSTED) == UNKNOWN -def test_not_through_the_gateway() -> None: - headers = {"host": "127.0.0.1:43100", "x-auth-request-user": ALENA} - assert signed_in_person(headers, "app", HOSTED) == Unavailable("not-through-gateway") +def test_not_hosted_is_local_whatever_it_carries() -> None: + headers = {**_person(entry="app"), "host": "127.0.0.1:43100", GATEWAY_HEADERS["app_role"]: "user"} + assert app_access(headers, "app", APP) == AppAccess("local", "admin", None, reason="not-hosted") -@pytest.mark.parametrize("subject", [None, "", ALENA.upper(), ALENA + "0", "alena"]) -def test_no_person(subject: str | None) -> None: - headers = {"host": HOST} if subject is None else {"host": HOST, "x-auth-request-user": subject} - assert signed_in_person(headers, "app", HOSTED) == Unavailable("no-person") +def test_not_through_the_gateway_is_local() -> None: + headers = {**_person(), "host": "127.0.0.1:43100"} + assert app_access(headers, "app", HOSTED) == AppAccess( + "local", "admin", None, reason="not-through-gateway" + ) + + +def test_local_entry_is_local_without_a_name() -> None: + headers = {"host": HOST, GATEWAY_HEADERS["entry"]: "local"} + assert app_access(headers, "app", HOSTED) == AppAccess("local", "admin", None, reason="local-entry") + + +@pytest.mark.parametrize( + "headers", + [ + {"host": HOST}, + {"host": HOST, GATEWAY_HEADERS["subject"]: ALENA}, + {**_person(), GATEWAY_HEADERS["entry"]: "full"}, + {**_person(), GATEWAY_HEADERS["entry"]: "local"}, + _person(""), + _person(ALENA.upper()), + _person(ALENA + "0"), + _person("alena"), + ], +) +def test_missing_or_malformed_statement_is_unknown(headers: dict[str, str]) -> None: + assert app_access(headers, "app", HOSTED) == UNKNOWN -def test_repeated_subject_is_nobody() -> None: - headers = {"host": HOST, "x-auth-request-user": ALENA, "X-Auth-Request-User": ALENA} - assert signed_in_person(headers, "app", HOSTED) == Unavailable("no-person") +def test_repeated_subject_is_unknown() -> None: + headers = {**_person(), "X-Auth-Request-User": ALENA} + assert app_access(headers, "app", HOSTED) == UNKNOWN @pytest.mark.parametrize("username", ["Alena", "al", "a" * 40, "-alena", ALENA, "service-account-pilot"]) def test_invalid_username_is_none(username: str) -> None: - headers = {"host": HOST, "x-auth-request-user": ALENA, "x-auth-request-preferred-username": username} - assert signed_in_person(headers, "app", HOSTED) == SignedIn(ALENA, None) + headers = {**_person(), GATEWAY_HEADERS["username"]: username} + assert app_access(headers, "app", HOSTED).person == AppPerson(ALENA, None) diff --git a/src/index.ts b/src/index.ts index 9ba5926..e987f68 100644 --- a/src/index.ts +++ b/src/index.ts @@ -339,37 +339,73 @@ export function requireEnvironment( >; } -/** Who is signed in to Lazurio for one incoming request, or why that is - * unavailable. Read-only identity for display and the Module's own rules: - * never a role, a grant, a key to data or a signature. */ -export type SignedInPerson = +/** A person the Environment's gateway admitted. */ +export type AppPerson = Readonly<{ + /** The Lazurio account's subject, a lowercase UUID: stable for the + * account, the key to remember a person by. */ + subject: string; + /** The person's Lazurio username, to show; null when the gateway's answer + * named none. */ + username: string | null; +}>; + +/** Who came to this App and with which application role (decision 0231). + * The stable contract of Lazurio for a Module: its fields and values never + * change meaning or disappear, and new information comes as new fields. + * + * - `environment`: the person has the whole Environment where the App runs, + * so they are an application admin. + * - `app`: the person has this App only, with the application role the + * Organization gave them. + * - `local`: a request from inside the Environment (a person's own computer + * with no gateway, a sibling or a script on the Environment, or the + * Environment browser's local entry): an application admin without a name. + * - `unknown`: a request that came through the gateway without identity + * (a gateway without this capability): no application role at all. + * + * What application admin and application user may do is the Organization's + * rule in the Module's code, enforced on the server for every request. The + * answer is no key to data and signs nothing. */ +export type AppAccess = | Readonly<{ - status: "signed-in"; - /** The Lazurio account's subject, a lowercase UUID: stable for the - * account, the key to remember a person by. */ - subject: string; - /** The person's Lazurio username, to show; null when the gateway's - * answer named none. */ - username: string | null; + entry: "environment"; + appRole: "admin"; + person: AppPerson; + /** Who states it: the Environment's gateway, or later an identity + * signed by Lazurio Auth for use outside the Environment. */ + source: "gateway" | "auth"; }> | Readonly<{ - status: "unavailable"; - /** `not-hosted`: no gateway in front of this listener (a person's - * computer, or an Environment without one), so no request names - * anyone. `not-through-gateway`: the request's `Host` is not the - * listener's external origin (a sibling, a script on the Environment). - * `no-person`: the gateway named nobody (the Environment browser's - * local entry, a gateway without this capability). */ - reason: "not-hosted" | "not-through-gateway" | "no-person"; + entry: "app"; + appRole: "admin" | "user"; + person: AppPerson; + source: "gateway" | "auth"; + }> + | Readonly<{ + entry: "local"; + appRole: "admin"; + person: null; + reason: "not-hosted" | "not-through-gateway" | "local-entry"; + }> + | Readonly<{ + entry: "unknown"; + appRole: null; + person: null; + reason: "no-identity"; }>; -/** What `signedInPerson` reads of a request: a `Request` or its headers. */ +/** What `appAccess` reads of a request: a `Request` or its headers. */ export type RequestHeaders = Readonly<{ get(name: string): string | null }>; -/** The headers the Environment's gateway sets after it admits a request - * (Lazurio Module Standard, "Who is signed in"). */ -export const SIGNED_IN_SUBJECT_HEADER = "x-auth-request-user"; -export const SIGNED_IN_USERNAME_HEADER = "x-auth-request-preferred-username"; +/** How the Environment's gateway carries its statement to the App. Internal + * transport, not a contract: only `appAccess` reads it, and it may change + * without changing `appAccess`. Exported for a Module's own tests. */ +export const GATEWAY_HEADERS = Object.freeze({ + subject: "x-auth-request-user", + username: "x-auth-request-preferred-username", + entry: "x-lazurio-entry", + appRole: "x-lazurio-app-role", +}); const SUBJECT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @@ -389,38 +425,74 @@ export function isLazurioUsername(value: unknown): value is string { ); } -/** Who is signed in to Lazurio for this request, read from the headers the - * Environment's gateway sets from its own admission answer. +/** Who came to this App and with which application role, for one request + * (decision 0231; Lazurio Module Standard, "Who came and with which role"). * - * The headers count only behind the gateway: when the listener has an - * external origin (`LAZURIO_RUNTIME_LISTENER__EXTERNAL_ORIGIN`, a hosted - * entrypoint) and the request's `Host` is that origin's host. Everywhere - * else the result is `unavailable` whatever the request carries, because - * there a header could come from anyone. The gateway removes every client + * The gateway's statement counts only when the listener has an external + * origin (`LAZURIO_RUNTIME_LISTENER__EXTERNAL_ORIGIN`, a hosted + * entrypoint) and the request's `Host` is that origin's host; any other + * request came from inside the Environment and is `local`. Behind the + * gateway a statement that is missing, repeated or of another form is + * `unknown`, never an application role. The gateway removes every client * copy and admits every request again, so read it per request and never - * cache it: after another account signs in, the next request names the new - * person, and an expired session never reaches the Module. A repeated header - * or a value of another form reads as nobody. Throws `listener-missing` like - * `listener(id)`. */ -export function signedInPerson( + * cache it. Inside the Environment the statement holds as far as the + * Environment does: a process on it that reaches the App's loopback port + * could send anything, as it could read the App's data (decision 0196). + * Throws `listener-missing` like `listener(id)`. */ +export function appAccess( request: Request | RequestHeaders, id = "app", env: Environment = process.env, -): SignedInPerson { +): AppAccess { const { externalOrigin } = listener(id, env); if (externalOrigin === null) - return { status: "unavailable", reason: "not-hosted" }; + return { + entry: "local", + appRole: "admin", + person: null, + reason: "not-hosted", + }; const headers: RequestHeaders = "headers" in request ? request.headers : request; if (headers.get("host") !== new URL(externalOrigin).host) - return { status: "unavailable", reason: "not-through-gateway" }; - const subject = headers.get(SIGNED_IN_SUBJECT_HEADER); - if (subject === null || !SUBJECT.test(subject)) - return { status: "unavailable", reason: "no-person" }; - const username = headers.get(SIGNED_IN_USERNAME_HEADER); - return { - status: "signed-in", + return { + entry: "local", + appRole: "admin", + person: null, + reason: "not-through-gateway", + }; + const entry = headers.get(GATEWAY_HEADERS.entry); + const subject = headers.get(GATEWAY_HEADERS.subject); + if (entry === "local" && subject === null) + return { + entry: "local", + appRole: "admin", + person: null, + reason: "local-entry", + }; + const unknown: AppAccess = { + entry: "unknown", + appRole: null, + person: null, + reason: "no-identity", + }; + if (subject === null || !SUBJECT.test(subject)) return unknown; + const username = headers.get(GATEWAY_HEADERS.username); + const person: AppPerson = { subject, username: isLazurioUsername(username) ? username : null, }; + if (entry === "environment") + return { + entry: "environment", + appRole: "admin", + person, + source: "gateway", + }; + if (entry === "app") { + const appRole = headers.get(GATEWAY_HEADERS.appRole); + if (appRole === "admin" || appRole === "user") + return { entry: "app", appRole, person, source: "gateway" }; + } + return unknown; } diff --git a/test/index.test.ts b/test/index.test.ts index bdce230..dd3db3d 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test"; import { EventEmitter } from "node:events"; import { allowedHosts, + appAccess, astroServerOptions, + GATEWAY_HEADERS, health, isLazurioUsername, listener, @@ -13,7 +15,6 @@ import { requireEnvironment, runtimeIdentity, type ShutdownProcess, - signedInPerson, viteServerOptions, withHealth, } from "../src/index.ts"; @@ -494,81 +495,134 @@ test("viteShutdownPlugin exits 0 as soon as close() resolves", async () => { expect(fake.exits).toEqual([0]); }); -describe("signedInPerson", () => { +describe("appAccess", () => { const HOST = "deals.vm-01.example.lazurio.io"; const ALENA = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; + const BOB = "9a8b7c6d-5e4f-4321-8fed-cba987654321"; const through = (extra: Record = {}) => new Request(`https://${HOST}/api/me`, { headers: { host: HOST, ...extra }, }); + const person = (subject = ALENA, entry = "environment") => ({ + [GATEWAY_HEADERS.subject]: subject, + [GATEWAY_HEADERS.username]: "alena", + [GATEWAY_HEADERS.entry]: entry, + }); + const unknown = { + entry: "unknown", + appRole: null, + person: null, + reason: "no-identity", + } as const; + + test("the whole Environment: an application admin with the person", () => { + expect(appAccess(through(person()), "app", HOSTED)).toEqual({ + entry: "environment", + appRole: "admin", + person: { subject: ALENA, username: "alena" }, + source: "gateway", + }); + }); - test("behind the gateway: the subject and username it set", () => { - expect( - signedInPerson( - through({ - "X-Auth-Request-User": ALENA, - "X-Auth-Request-Preferred-Username": "alena", - }), - "app", - HOSTED, - ), - ).toEqual({ status: "signed-in", subject: ALENA, username: "alena" }); + test("this App only: the application role the gateway stated", () => { + for (const appRole of ["admin", "user"] as const) + expect( + appAccess( + through({ + ...person(ALENA, "app"), + [GATEWAY_HEADERS.appRole]: appRole, + }), + "app", + HOSTED, + ), + ).toEqual({ + entry: "app", + appRole, + person: { subject: ALENA, username: "alena" }, + source: "gateway", + }); + }); + + test("this App only without a valid role is unknown, never a role", () => { + for (const role of [undefined, "", "Admin", "owner", "admin, user"]) + expect( + appAccess( + through({ + ...person(ALENA, "app"), + ...(role === undefined ? {} : { [GATEWAY_HEADERS.appRole]: role }), + }), + "app", + HOSTED, + ), + ).toEqual(unknown); }); test("reads plain headers too (any server)", () => { - const headers = new Headers({ host: HOST, "x-auth-request-user": ALENA }); - expect(signedInPerson(headers, "app", HOSTED)).toEqual({ - status: "signed-in", - subject: ALENA, - username: null, + const headers = new Headers({ host: HOST, ...person() }); + expect(appAccess(headers, "app", HOSTED)).toMatchObject({ + entry: "environment", + appRole: "admin", }); }); - test("without a gateway no header names anyone", () => { + test("without a gateway every request is local, whatever it carries", () => { const forged = new Request("http://127.0.0.1:43100/", { headers: { host: "127.0.0.1:43100", - "x-auth-request-user": ALENA, - "x-auth-request-preferred-username": "alena", + ...person(ALENA, "app"), + [GATEWAY_HEADERS.appRole]: "user", }, }); - expect(signedInPerson(forged, "app", APP)).toEqual({ - status: "unavailable", + expect(appAccess(forged, "app", APP)).toEqual({ + entry: "local", + appRole: "admin", + person: null, reason: "not-hosted", }); }); - test("a request that did not come through the gateway is nobody", () => { + test("a request that did not come through the gateway is local", () => { for (const host of ["127.0.0.1:43100", "notes.vm-01.example.lazurio.io"]) expect( - signedInPerson( - new Request(`http://${host}/`, { - headers: { host, "x-auth-request-user": ALENA }, - }), + appAccess( + new Request(`http://${host}/`, { headers: { host, ...person() } }), "app", HOSTED, ), - ).toEqual({ status: "unavailable", reason: "not-through-gateway" }); + ).toEqual({ + entry: "local", + appRole: "admin", + person: null, + reason: "not-through-gateway", + }); }); - test("no subject, a malformed one or a repeated header is nobody", () => { - for (const subject of [ - undefined, - "", - ALENA.toUpperCase(), - `${ALENA}0`, - "alena", - `${ALENA}, 9a8b7c6d-5e4f-4321-8fed-cba987654321`, - ]) - expect( - signedInPerson( - through( - subject === undefined ? {} : { "x-auth-request-user": subject }, - ), - "app", - HOSTED, - ), - ).toEqual({ status: "unavailable", reason: "no-person" }); + test("the Environment browser's local entry is local without a name", () => { + expect( + appAccess(through({ [GATEWAY_HEADERS.entry]: "local" }), "app", HOSTED), + ).toEqual({ + entry: "local", + appRole: "admin", + person: null, + reason: "local-entry", + }); + }); + + test("through the gateway, a missing or malformed statement is unknown", () => { + const cases: Record[] = [ + {}, + { [GATEWAY_HEADERS.subject]: ALENA }, + { ...person(), [GATEWAY_HEADERS.entry]: "full" }, + { ...person(), [GATEWAY_HEADERS.entry]: "local" }, + { ...person(""), [GATEWAY_HEADERS.entry]: "environment" }, + person(ALENA.toUpperCase()), + person(`${ALENA}0`), + person("alena"), + person(`${ALENA}, ${BOB}`), + { ...person(), [GATEWAY_HEADERS.entry]: "environment, app" }, + ]; + for (const headers of cases) + expect(appAccess(through(headers), "app", HOSTED)).toEqual(unknown); }); test("a username outside the Lazurio rule is no username", () => { @@ -583,30 +637,26 @@ describe("signedInPerson", () => { "alena, bob", ]) expect( - signedInPerson( - through({ - "x-auth-request-user": ALENA, - "x-auth-request-preferred-username": username, - }), + appAccess( + through({ ...person(), [GATEWAY_HEADERS.username]: username }), "app", HOSTED, - ), - ).toEqual({ status: "signed-in", subject: ALENA, username: null }); + ).person, + ).toEqual({ subject: ALENA, username: null }); expect(isLazurioUsername("bob-example")).toBe(true); }); test("per request: another account on the next request is that person", () => { - const bob = "9a8b7c6d-5e4f-4321-8fed-cba987654321"; - expect( - signedInPerson(through({ "x-auth-request-user": ALENA }), "app", HOSTED), - ).toMatchObject({ subject: ALENA }); - expect( - signedInPerson(through({ "x-auth-request-user": bob }), "app", HOSTED), - ).toMatchObject({ subject: bob }); + expect(appAccess(through(person()), "app", HOSTED).person).toMatchObject({ + subject: ALENA, + }); + expect(appAccess(through(person(BOB)), "app", HOSTED).person).toMatchObject( + { subject: BOB }, + ); }); test("an App started outside Lazurio fails like listener()", () => { - expect(thrown(() => signedInPerson(through(), "app", {})).code).toBe( + expect(thrown(() => appAccess(through(), "app", {})).code).toBe( "listener-missing", ); }); From e6f889545c32f2c27237d7306986615264dd8a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= <16311043+immakermatty@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:08:24 +0000 Subject: [PATCH 3/5] Python tests: type the health helper so pyright passes (CI) Co-Authored-By: Claude Opus 5.5 --- python/tests/test_module_kit.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/python/tests/test_module_kit.py b/python/tests/test_module_kit.py index 152cdeb..f721a10 100644 --- a/python/tests/test_module_kit.py +++ b/python/tests/test_module_kit.py @@ -1,5 +1,6 @@ import asyncio import json +from collections.abc import Awaitable, Callable from typing import Literal import pytest @@ -81,8 +82,11 @@ def test_reads_process_environment(monkeypatch: pytest.MonkeyPatch) -> None: assert listener("app").port == 43100 -def _run(ready: object) -> HealthResponse: - return asyncio.run(health(ready)()) +def _run(ready: Callable[[], bool | Awaitable[bool]]) -> HealthResponse: + async def check() -> HealthResponse: + return await health(ready)() + + return asyncio.run(check()) def test_health_ok() -> None: From de029b7debf21db4045604485547463699e979ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= <16311043+immakermatty@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:32:42 +0000 Subject: [PATCH 4/5] appAccess: every answer has the same five fields, and only a loopback Host is local (review) Review (Pablo): - A Host naming the external hostname in another letter case, with a port or a trailing dot reaches the App through the gateway, yet read as local admin. Now the hostname is normalized as the gateway matches it: such a request counts only by the gateway's statement (unknown without one), a loopback Host (127.x, localhost, [::1]) is local, any other Host unknown. - Python collapsed a repeated subject to absent, so with a local entry it read as local admin. Absent and repeated now differ; a local entry carries no person, username or role, otherwise it is unknown. - source and reason are present in every answer (source null and reason set without a person, reason null with one), in TypeScript and Python. Co-Authored-By: Claude Opus 5.5 --- README.md | 28 +++-- python/src/lazurio_module_kit/__init__.py | 86 ++++++++++----- python/tests/test_module_kit.py | 76 +++++++++++--- src/index.ts | 98 ++++++++++------- test/index.test.ts | 122 +++++++++++++++++----- 5 files changed, 298 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index dbba1d2..e9694ad 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ try { | `onShutdown(fn, { timeoutMs = 10_000 })` | on `SIGTERM`/`SIGINT`, runs `fn` once and exits 0; exits 1 if `fn` throws or does not finish within `timeoutMs`; returns a function that removes the handlers | | `requireEnvironment(names, env)` | the values as a record; throws `environment-missing` naming every absent or empty variable | -| `appAccess(request, id = "app", env)` | who came to the App for this request and with which application role: `{ entry, appRole, person, source }` or, without a person, `{ entry, appRole, person: null, reason }`, see [Who came and with which role](#who-came-and-with-which-role) | +| `appAccess(request, id = "app", env)` | who came to the App for this request and with which application role: always `{ entry, appRole, person, source, reason }`, see [Who came and with which role](#who-came-and-with-which-role) | | `isLazurioUsername(value)` | whether a value is a Lazurio username (3–39 lowercase letters and digits, single dashes between them, not UUID-shaped, not a service account) | `ModuleKitError.code` is one of `listener-missing`, `listeners-invalid` or @@ -88,16 +88,22 @@ const mayApprove = access.appRole === "admin"; // the Organization's own rule const author = access.person?.subject ?? null; // null: from inside the Environment ``` -| `entry` | `appRole` | `person` | When | -| --- | --- | --- | --- | -| `environment` | `admin` | subject and username | the person has the whole Environment where the App runs | -| `app` | `admin` or `user` | subject and username | the person has this App only, with the role the Organization gave them | -| `local` | `admin` | `null` | a request from inside the Environment: a person's own computer (`reason: "not-hosted"`), a sibling or a script on the Environment (`not-through-gateway`), the Environment browser's local entry (`local-entry`) | -| `unknown` | `null` | `null` | through the gateway without identity (`no-identity`), for example a gateway release without this capability: no role, show "unavailable" | - -`source` is `gateway` today: the Environment's gateway states it from its own -admission answer after removing every copy a client sent. An identity signed by -Lazurio Auth for use outside the Environment will come as `source: "auth"`. +Every answer has the same five fields: + +| `entry` | `appRole` | `person` | `source` | `reason` | When | +| --- | --- | --- | --- | --- | --- | +| `environment` | `admin` | subject and username | `gateway` | `null` | the person has the whole Environment where the App runs | +| `app` | `admin` or `user` | subject and username | `gateway` | `null` | the person has this App only, with the role the Organization gave them | +| `local` | `admin` | `null` | `null` | `not-hosted`, `not-through-gateway`, `local-entry` | from inside the Environment: no gateway in front of the App (a person's own computer), a request to a loopback address (a sibling, a script, an agent), or the Environment browser's local entry | +| `unknown` | `null` | `null` | `null` | `no-identity` | through the gateway without a valid statement, or a `Host` that is neither the App's hostname nor loopback: no role, show "unavailable" | + +`source` says who stated the person: `gateway` today, from its own admission +answer after removing every copy a client sent; an identity signed by Lazurio +Auth for use outside the Environment will come as `auth`. A request whose +`Host` names the App's external hostname, in any letter case, with any port or +a trailing dot, as the gateway matches it, counts only by the gateway's +statement; a missing, repeated or malformed statement is `unknown`, never a +role. - **Enforce on the server, per request.** Check the role for every request, including APIs, downloads, WebSockets and direct URLs, never only by hiding a diff --git a/python/src/lazurio_module_kit/__init__.py b/python/src/lazurio_module_kit/__init__.py index 02c06b2..716f19c 100644 --- a/python/src/lazurio_module_kit/__init__.py +++ b/python/src/lazurio_module_kit/__init__.py @@ -168,20 +168,22 @@ class AppPerson: @dataclass(frozen=True) class AppAccess: """Who came to this App and with which application role (decision 0231); - the same contract as ``appAccess`` of the TypeScript package. + the same contract as ``appAccess`` of the TypeScript package, with the + same five fields in every answer. ``entry`` is ``environment`` (the whole Environment, so ``app_role`` is ``admin``), ``app`` (this App only, with the role the Organization gave), ``local`` (a request from inside the Environment: an application admin - without a name) or ``unknown`` (through the gateway without identity: no - ``app_role``). ``source`` is set with a person, ``reason`` without one. - Fields and values never change meaning or disappear.""" + without a name) or ``unknown`` (no role). With a person ``source`` says + who stated it and ``reason`` is ``None``; without one ``source`` is + ``None`` and ``reason`` says why. Fields and values never change meaning + or disappear.""" entry: Entry app_role: AppRole | None person: AppPerson | None - source: Source | None = None - reason: Reason | None = None + source: Source | None + reason: Reason | None def is_lazurio_username(value: object) -> bool: @@ -196,10 +198,34 @@ def is_lazurio_username(value: object) -> bool: ) -def _header(headers: Mapping[str, str], name: str) -> str | None: - """One header, matched case-insensitively; a repeated one reads as none.""" +class _Repeated: + """A header sent more than once: never one valid value.""" + + +_REPEATED = _Repeated() + + +def _header(headers: Mapping[str, str], name: str) -> str | _Repeated | None: + """One header, matched case-insensitively: its value, ``None`` when absent, + ``_REPEATED`` when sent more than once.""" values = [value for key, value in headers.items() if key.lower() == name] - return values[0] if len(values) == 1 else None + if not values: + return None + return values[0] if len(values) == 1 else _REPEATED + + +_HOST = re.compile(r"(\[[^\]]*\]|[^:\[\]]*)(?::[0-9]*)?") +_LOOPBACK_HOST = re.compile(r"127(?:\.[0-9]{1,3}){3}|localhost|\[::1\]") + + +def _hostname(host: str | _Repeated | None) -> str | None: + """The hostname a Host header names, lowercased, without a port and a + trailing dot, as the gateway matches it; ``None`` when it names none.""" + if not isinstance(host, str): + return None + match = _HOST.fullmatch(host.strip()) + name = match.group(1).lower().rstrip(".") if match else "" + return name or None def app_access( @@ -208,32 +234,38 @@ def app_access( env: Mapping[str, str] | None = None, ) -> AppAccess: """Who came to this App and with which application role, for one request; - the same rules as ``appAccess`` of the TypeScript package. The gateway's - statement counts only when the listener has an external origin and the - request's Host is that origin's host; any other request is ``local``. - Behind the gateway a missing, repeated or malformed statement is - ``unknown``, never a role. Read it per request, never cache it. Raises + the same rules as ``appAccess`` of the TypeScript package. Without an + external origin every request is ``local``. A request whose Host names the + external origin's hostname (any letter case, any port) counts only by the + gateway's statement, and a missing, repeated or malformed statement is + ``unknown``, never a role. A loopback Host is ``local``; any other Host is + ``unknown``. Read it per request, never cache it. Raises ``listener-missing`` like ``listener``.""" origin = listener(listener_id, env).external_origin + unknown = AppAccess("unknown", None, None, None, "no-identity") if origin is None: - return AppAccess("local", "admin", None, reason="not-hosted") - if _header(headers, "host") != urlsplit(origin).netloc: - return AppAccess("local", "admin", None, reason="not-through-gateway") + return AppAccess("local", "admin", None, None, "not-hosted") + host = _hostname(_header(headers, "host")) + if host != _hostname(urlsplit(origin).netloc): + if host is not None and _LOOPBACK_HOST.fullmatch(host): + return AppAccess("local", "admin", None, None, "not-through-gateway") + return unknown entry = _header(headers, GATEWAY_HEADERS["entry"]) subject = _header(headers, GATEWAY_HEADERS["subject"]) - if entry == "local" and subject is None: - return AppAccess("local", "admin", None, reason="local-entry") - unknown = AppAccess("unknown", None, None, reason="no-identity") - if subject is None or _SUBJECT.fullmatch(subject) is None: - return unknown username = _header(headers, GATEWAY_HEADERS["username"]) - person = AppPerson(subject, username if is_lazurio_username(username) else None) + role = _header(headers, GATEWAY_HEADERS["app_role"]) + if entry == "local" and subject is None and username is None and role is None: + return AppAccess("local", "admin", None, None, "local-entry") + if not isinstance(subject, str) or _SUBJECT.fullmatch(subject) is None: + return unknown + person = AppPerson( + subject, username if isinstance(username, str) and is_lazurio_username(username) else None + ) if entry == "environment": - return AppAccess("environment", "admin", person, source="gateway") + return AppAccess("environment", "admin", person, "gateway", None) if entry == "app": - role = _header(headers, GATEWAY_HEADERS["app_role"]) if role == "admin": - return AppAccess("app", "admin", person, source="gateway") + return AppAccess("app", "admin", person, "gateway", None) if role == "user": - return AppAccess("app", "user", person, source="gateway") + return AppAccess("app", "user", person, "gateway", None) return unknown diff --git a/python/tests/test_module_kit.py b/python/tests/test_module_kit.py index f721a10..14115b5 100644 --- a/python/tests/test_module_kit.py +++ b/python/tests/test_module_kit.py @@ -115,12 +115,17 @@ def broken() -> bool: HOST = "deals.vm-01.example.lazurio.io" HOSTED = {**APP, "LAZURIO_RUNTIME_LISTENER_APP_EXTERNAL_ORIGIN": f"https://{HOST}"} ALENA = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" -UNKNOWN = AppAccess("unknown", None, None, reason="no-identity") +BOB = "9a8b7c6d-5e4f-4321-8fed-cba987654321" +UNKNOWN = AppAccess("unknown", None, None, None, "no-identity") -def _person(subject: str = ALENA, entry: str = "environment") -> dict[str, str]: +def _local(reason: Literal["not-hosted", "not-through-gateway", "local-entry"]) -> AppAccess: + return AppAccess("local", "admin", None, None, reason) + + +def _person(subject: str = ALENA, entry: str = "environment", host: str = HOST) -> dict[str, str]: return { - "host": HOST, + "host": host, GATEWAY_HEADERS["subject"]: subject, GATEWAY_HEADERS["username"]: "alena", GATEWAY_HEADERS["entry"]: entry, @@ -135,7 +140,7 @@ def test_whole_environment_is_an_application_admin() -> None: "X-Lazurio-Entry": "environment", } assert app_access(headers, "app", HOSTED) == AppAccess( - "environment", "admin", AppPerson(ALENA, "alena"), source="gateway" + "environment", "admin", AppPerson(ALENA, "alena"), "gateway", None ) @@ -143,7 +148,7 @@ def test_whole_environment_is_an_application_admin() -> None: def test_this_app_only_has_the_stated_role(role: Literal["admin", "user"]) -> None: headers = {**_person(entry="app"), GATEWAY_HEADERS["app_role"]: role} assert app_access(headers, "app", HOSTED) == AppAccess( - "app", role, AppPerson(ALENA, "alena"), source="gateway" + "app", role, AppPerson(ALENA, "alena"), "gateway", None ) @@ -155,21 +160,44 @@ def test_this_app_only_without_a_valid_role_is_unknown(role: str | None) -> None assert app_access(headers, "app", HOSTED) == UNKNOWN +@pytest.mark.parametrize( + "host", + [ + "DEALS.VM-01.EXAMPLE.LAZURIO.IO", + f"{HOST}:443", + f"{HOST}:8443", + f"{HOST}.", + "Deals.Vm-01.Example.Lazurio.Io.:443", + ], +) +def test_the_gateway_hostname_in_any_form_counts_only_by_its_statement(host: str) -> None: + visitor = {**_person(entry="app", host=host), GATEWAY_HEADERS["app_role"]: "user"} + assert app_access(visitor, "app", HOSTED).app_role == "user" + assert app_access({"host": host}, "app", HOSTED) == UNKNOWN + + def test_not_hosted_is_local_whatever_it_carries() -> None: - headers = {**_person(entry="app"), "host": "127.0.0.1:43100", GATEWAY_HEADERS["app_role"]: "user"} - assert app_access(headers, "app", APP) == AppAccess("local", "admin", None, reason="not-hosted") + headers = {**_person(entry="app", host="127.0.0.1:43100"), GATEWAY_HEADERS["app_role"]: "user"} + assert app_access(headers, "app", APP) == _local("not-hosted") -def test_not_through_the_gateway_is_local() -> None: - headers = {**_person(), "host": "127.0.0.1:43100"} - assert app_access(headers, "app", HOSTED) == AppAccess( - "local", "admin", None, reason="not-through-gateway" - ) +@pytest.mark.parametrize( + "host", ["127.0.0.1:43100", "127.0.0.1", "localhost:43100", "LOCALHOST", "[::1]:43100"] +) +def test_a_loopback_host_is_local(host: str) -> None: + assert app_access(_person(host=host), "app", HOSTED) == _local("not-through-gateway") + + +@pytest.mark.parametrize( + "host", ["notes.vm-01.example.lazurio.io", f"{HOST}.evil.test", "evil.test", "", "127.0.0.1.evil.test"] +) +def test_any_other_host_is_unknown(host: str) -> None: + assert app_access(_person(host=host), "app", HOSTED) == UNKNOWN def test_local_entry_is_local_without_a_name() -> None: headers = {"host": HOST, GATEWAY_HEADERS["entry"]: "local"} - assert app_access(headers, "app", HOSTED) == AppAccess("local", "admin", None, reason="local-entry") + assert app_access(headers, "app", HOSTED) == _local("local-entry") @pytest.mark.parametrize( @@ -183,6 +211,8 @@ def test_local_entry_is_local_without_a_name() -> None: _person(ALENA.upper()), _person(ALENA + "0"), _person("alena"), + {"host": HOST, GATEWAY_HEADERS["entry"]: "local", GATEWAY_HEADERS["username"]: "alena"}, + {"host": HOST, GATEWAY_HEADERS["entry"]: "local", GATEWAY_HEADERS["app_role"]: "user"}, ], ) def test_missing_or_malformed_statement_is_unknown(headers: dict[str, str]) -> None: @@ -194,6 +224,26 @@ def test_repeated_subject_is_unknown() -> None: assert app_access(headers, "app", HOSTED) == UNKNOWN +def test_repeated_subject_with_a_local_entry_is_unknown() -> None: + headers = { + "host": HOST, + GATEWAY_HEADERS["entry"]: "local", + "x-auth-request-user": ALENA, + "X-Auth-Request-User": BOB, + } + assert app_access(headers, "app", HOSTED) == UNKNOWN + + +def test_repeated_local_entry_is_unknown() -> None: + headers = {"host": HOST, "x-lazurio-entry": "local", "X-Lazurio-Entry": "local"} + assert app_access(headers, "app", HOSTED) == UNKNOWN + + +def test_repeated_host_is_unknown() -> None: + headers = {**_person(), "Host": HOST} + assert app_access(headers, "app", HOSTED) == UNKNOWN + + @pytest.mark.parametrize("username", ["Alena", "al", "a" * 40, "-alena", ALENA, "service-account-pilot"]) def test_invalid_username_is_none(username: str) -> None: headers = {**_person(), GATEWAY_HEADERS["username"]: username} diff --git a/src/index.ts b/src/index.ts index e987f68..95fe1e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -374,23 +374,28 @@ export type AppAccess = /** Who states it: the Environment's gateway, or later an identity * signed by Lazurio Auth for use outside the Environment. */ source: "gateway" | "auth"; + reason: null; }> | Readonly<{ entry: "app"; appRole: "admin" | "user"; person: AppPerson; source: "gateway" | "auth"; + reason: null; }> | Readonly<{ entry: "local"; appRole: "admin"; person: null; + /** No person was stated, so nobody stated it. */ + source: null; reason: "not-hosted" | "not-through-gateway" | "local-entry"; }> | Readonly<{ entry: "unknown"; appRole: null; person: null; + source: null; reason: "no-identity"; }>; @@ -425,57 +430,75 @@ export function isLazurioUsername(value: unknown): value is string { ); } +/** The hostname a `Host` header names, lowercased, without a port and a + * trailing dot, as the gateway matches it; null when it names none. */ +function hostnameOf(host: string | null): string | null { + if (host === null) return null; + const match = /^(\[[^\]]*\]|[^:[\]]*)(?::[0-9]*)?$/.exec(host.trim()); + const name = match?.[1]?.toLowerCase().replace(/\.$/, "") ?? ""; + return name === "" ? null : name; +} + +const LOOPBACK_HOST = /^(?:127(?:\.[0-9]{1,3}){3}|localhost|\[::1\])$/; + /** Who came to this App and with which application role, for one request * (decision 0231; Lazurio Module Standard, "Who came and with which role"). * - * The gateway's statement counts only when the listener has an external - * origin (`LAZURIO_RUNTIME_LISTENER__EXTERNAL_ORIGIN`, a hosted - * entrypoint) and the request's `Host` is that origin's host; any other - * request came from inside the Environment and is `local`. Behind the - * gateway a statement that is missing, repeated or of another form is - * `unknown`, never an application role. The gateway removes every client - * copy and admits every request again, so read it per request and never - * cache it. Inside the Environment the statement holds as far as the - * Environment does: a process on it that reaches the App's loopback port - * could send anything, as it could read the App's data (decision 0196). - * Throws `listener-missing` like `listener(id)`. */ + * The answer has the same five fields in every case. Without an external + * origin (`LAZURIO_RUNTIME_LISTENER__EXTERNAL_ORIGIN`) no gateway stands + * in front of the App and every request is `local`. With one, a request whose + * `Host` names the external origin's hostname, in any letter case and with + * any port, as the gateway matches it, counts only by the gateway's statement: + * a missing, repeated or malformed statement is `unknown`, never a role. A + * request to a loopback address (a sibling, a script, an agent) is `local`; + * any other `Host` is `unknown`. The gateway removes every client copy and + * admits every request again, so read it per request and never cache it. + * Inside the Environment the answer holds as far as the Environment does: a + * process on it that reaches the App's loopback port could send anything, as + * it could read the App's data (decision 0196), so it is no authentication + * boundary inside the Environment. Throws `listener-missing` like + * `listener(id)`. */ export function appAccess( request: Request | RequestHeaders, id = "app", env: Environment = process.env, ): AppAccess { const { externalOrigin } = listener(id, env); - if (externalOrigin === null) - return { - entry: "local", - appRole: "admin", - person: null, - reason: "not-hosted", - }; - const headers: RequestHeaders = - "headers" in request ? request.headers : request; - if (headers.get("host") !== new URL(externalOrigin).host) - return { - entry: "local", - appRole: "admin", - person: null, - reason: "not-through-gateway", - }; - const entry = headers.get(GATEWAY_HEADERS.entry); - const subject = headers.get(GATEWAY_HEADERS.subject); - if (entry === "local" && subject === null) - return { - entry: "local", - appRole: "admin", - person: null, - reason: "local-entry", - }; + const local = ( + reason: "not-hosted" | "not-through-gateway" | "local-entry", + ): AppAccess => ({ + entry: "local", + appRole: "admin", + person: null, + source: null, + reason, + }); const unknown: AppAccess = { entry: "unknown", appRole: null, person: null, + source: null, reason: "no-identity", }; + if (externalOrigin === null) return local("not-hosted"); + const headers: RequestHeaders = + "headers" in request ? request.headers : request; + const host = hostnameOf(headers.get("host")); + if (host !== hostnameOf(new URL(externalOrigin).host)) + return host !== null && LOOPBACK_HOST.test(host) + ? local("not-through-gateway") + : unknown; + // Through the gateway: only its statement counts. A repeated header reads + // as its values joined with a comma, so it never matches one valid value. + const entry = headers.get(GATEWAY_HEADERS.entry); + const subject = headers.get(GATEWAY_HEADERS.subject); + if ( + entry === "local" && + subject === null && + headers.get(GATEWAY_HEADERS.username) === null && + headers.get(GATEWAY_HEADERS.appRole) === null + ) + return local("local-entry"); if (subject === null || !SUBJECT.test(subject)) return unknown; const username = headers.get(GATEWAY_HEADERS.username); const person: AppPerson = { @@ -488,11 +511,12 @@ export function appAccess( appRole: "admin", person, source: "gateway", + reason: null, }; if (entry === "app") { const appRole = headers.get(GATEWAY_HEADERS.appRole); if (appRole === "admin" || appRole === "user") - return { entry: "app", appRole, person, source: "gateway" }; + return { entry: "app", appRole, person, source: "gateway", reason: null }; } return unknown; } diff --git a/test/index.test.ts b/test/index.test.ts index dd3db3d..189face 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -499,9 +499,9 @@ describe("appAccess", () => { const HOST = "deals.vm-01.example.lazurio.io"; const ALENA = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; const BOB = "9a8b7c6d-5e4f-4321-8fed-cba987654321"; - const through = (extra: Record = {}) => + const through = (extra: Record = {}, host = HOST) => new Request(`https://${HOST}/api/me`, { - headers: { host: HOST, ...extra }, + headers: { host, ...extra }, }); const person = (subject = ALENA, entry = "environment") => ({ [GATEWAY_HEADERS.subject]: subject, @@ -512,8 +512,19 @@ describe("appAccess", () => { entry: "unknown", appRole: null, person: null, + source: null, reason: "no-identity", } as const; + const local = ( + reason: "not-hosted" | "not-through-gateway" | "local-entry", + ) => + ({ + entry: "local", + appRole: "admin", + person: null, + source: null, + reason, + }) as const; test("the whole Environment: an application admin with the person", () => { expect(appAccess(through(person()), "app", HOSTED)).toEqual({ @@ -521,6 +532,7 @@ describe("appAccess", () => { appRole: "admin", person: { subject: ALENA, username: "alena" }, source: "gateway", + reason: null, }); }); @@ -540,9 +552,26 @@ describe("appAccess", () => { appRole, person: { subject: ALENA, username: "alena" }, source: "gateway", + reason: null, }); }); + test("every answer has the same five fields", () => { + for (const answer of [ + appAccess(through(person()), "app", HOSTED), + appAccess(through(), "app", HOSTED), + appAccess(through({}, "127.0.0.1:43100"), "app", HOSTED), + appAccess(through(), "app", APP), + ]) + expect(Object.keys(answer).sort()).toEqual([ + "appRole", + "entry", + "person", + "reason", + "source", + ]); + }); + test("this App only without a valid role is unknown, never a role", () => { for (const role of [undefined, "", "Admin", "owner", "admin, user"]) expect( @@ -565,6 +594,30 @@ describe("appAccess", () => { }); }); + test("the gateway's hostname in any letter case, with a port or a trailing dot, counts only by its statement", () => { + for (const host of [ + "DEALS.VM-01.EXAMPLE.LAZURIO.IO", + `${HOST}:443`, + `${HOST}:8443`, + `${HOST}.`, + `Deals.Vm-01.Example.Lazurio.Io.:443`, + ]) { + // A visitor with this App only stays a user, never a local admin. + expect( + appAccess( + through( + { ...person(ALENA, "app"), [GATEWAY_HEADERS.appRole]: "user" }, + host, + ), + "app", + HOSTED, + ), + ).toMatchObject({ entry: "app", appRole: "user" }); + // Without a statement it is unknown, never local. + expect(appAccess(through({}, host), "app", HOSTED)).toEqual(unknown); + } + }); + test("without a gateway every request is local, whatever it carries", () => { const forged = new Request("http://127.0.0.1:43100/", { headers: { @@ -573,42 +626,48 @@ describe("appAccess", () => { [GATEWAY_HEADERS.appRole]: "user", }, }); - expect(appAccess(forged, "app", APP)).toEqual({ - entry: "local", - appRole: "admin", - person: null, - reason: "not-hosted", - }); + expect(appAccess(forged, "app", APP)).toEqual(local("not-hosted")); }); - test("a request that did not come through the gateway is local", () => { - for (const host of ["127.0.0.1:43100", "notes.vm-01.example.lazurio.io"]) + test("a request to a loopback address is local", () => { + for (const host of [ + "127.0.0.1:43100", + "127.0.0.1", + "localhost:43100", + "LOCALHOST", + "[::1]:43100", + ]) expect( appAccess( - new Request(`http://${host}/`, { headers: { host, ...person() } }), + new Request("http://127.0.0.1:43100/", { + headers: { host, ...person() }, + }), "app", HOSTED, ), - ).toEqual({ - entry: "local", - appRole: "admin", - person: null, - reason: "not-through-gateway", - }); + ).toEqual(local("not-through-gateway")); + }); + + test("any other Host is unknown, never local", () => { + for (const host of [ + "notes.vm-01.example.lazurio.io", + "deals.vm-01.example.lazurio.io.evil.test", + "evil.test", + "", + "127.0.0.1.evil.test", + ]) + expect(appAccess(through(person(), host), "app", HOSTED)).toEqual( + unknown, + ); }); test("the Environment browser's local entry is local without a name", () => { expect( appAccess(through({ [GATEWAY_HEADERS.entry]: "local" }), "app", HOSTED), - ).toEqual({ - entry: "local", - appRole: "admin", - person: null, - reason: "local-entry", - }); + ).toEqual(local("local-entry")); }); - test("through the gateway, a missing or malformed statement is unknown", () => { + test("through the gateway, a missing, repeated or malformed statement is unknown", () => { const cases: Record[] = [ {}, { [GATEWAY_HEADERS.subject]: ALENA }, @@ -620,9 +679,24 @@ describe("appAccess", () => { person("alena"), person(`${ALENA}, ${BOB}`), { ...person(), [GATEWAY_HEADERS.entry]: "environment, app" }, + { [GATEWAY_HEADERS.entry]: "local, local" }, + { + [GATEWAY_HEADERS.entry]: "local", + [GATEWAY_HEADERS.username]: "alena", + }, + { + [GATEWAY_HEADERS.entry]: "local", + [GATEWAY_HEADERS.appRole]: "user", + }, ]; for (const headers of cases) expect(appAccess(through(headers), "app", HOSTED)).toEqual(unknown); + // A repeated subject next to a local entry is no local entry. + const repeated = new Headers({ host: HOST }); + repeated.append(GATEWAY_HEADERS.entry, "local"); + repeated.append(GATEWAY_HEADERS.subject, ALENA); + repeated.append(GATEWAY_HEADERS.subject, BOB); + expect(appAccess(repeated, "app", HOSTED)).toEqual(unknown); }); test("a username outside the Lazurio rule is no username", () => { From 4341986d059adeead353e1215a9f02d006214433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= <16311043+immakermatty@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:00:38 +0000 Subject: [PATCH 5/5] Proof through the real gateway: appAccess and app_access behind the Machines-rendered Caddy, for every Host form (review) bun proof/gateway.ts renders the workspace gateway from Machines, runs it with Caddy in front of a TypeScript and a Python App and checks the Host forms a client can send: as is, upper case, :443, another port (a person is environment/admin, an admitted session without a person is unknown, nothing without a session) and a trailing dot (Caddy refuses it with 421 and the App is never reached). A client's forged statement never counts and no request through the gateway is local. Typechecked and linted with the package; not part of bun test, since it needs Caddy, OpenSSL and uv. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++ biome.json | 1 + proof/gateway.ts | 305 +++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 4 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 proof/gateway.ts diff --git a/README.md b/README.md index e9694ad..9825a55 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,16 @@ role. gateway uses today so that a Module's own tests can build requests; read them only through `appAccess`. +**Proof through the real gateway.** `bun proof/gateway.ts ` +renders the Lazurio workspace gateway from a Machines checkout, runs it with +Caddy in front of a TypeScript App and a Python App and checks every Host form +a client can send: the App's hostname as is, in upper case, with `:443` or +another port (the gateway's statement counts: `environment`/admin with a +person, `unknown` without one, nothing without a session) and with a trailing +dot (refused by the gateway itself). A client's forged statement never counts +and no request through the gateway is `local`. It needs Caddy, OpenSSL and uv, +so it is not part of `bun test`. + Python: `app_access(headers, "app")` takes the request headers as a mapping (matched case-insensitively; a repeated header reads as none) and returns `AppAccess(entry, app_role, person, source, reason)` with diff --git a/biome.json b/biome.json index b2f36ea..f057955 100644 --- a/biome.json +++ b/biome.json @@ -4,6 +4,7 @@ "includes": [ "src/**/*.ts", "test/**/*.ts", + "proof/**/*.ts", "package.json", "tsconfig.json", "biome.json" diff --git a/proof/gateway.ts b/proof/gateway.ts new file mode 100644 index 0000000..fd41394 --- /dev/null +++ b/proof/gateway.ts @@ -0,0 +1,305 @@ +// Proof through the real gateway: the Lazurio workspace gateway (Machines' +// workspaceIngress rendered into a Caddyfile, run by the pinned Caddy) in +// front of two Apps, one reading `appAccess` (TypeScript) and one reading +// `app_access` (Python). It shows that a request through the gateway, with +// the App's hostname in any letter case, with a port or a trailing dot, +// counts only by the gateway's statement: a person is `environment`/admin, an +// admitted session without a person is `unknown`, a client's forged statement +// never counts, and no such request is ever `local`. +// +// bun proof/gateway.ts (CADDY_BIN, default `caddy`) +// +// Not part of `bun test`: it needs a Machines checkout, Caddy, OpenSSL and uv. +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { appAccess } from "../src/index.ts"; + +const machines = process.argv[2]; +if (!machines) + throw new Error("usage: bun proof/gateway.ts "); +const caddy = process.env.CADDY_BIN ?? "caddy"; +const kit = join(import.meta.dir, ".."); +const dir = mkdtempSync(join(tmpdir(), "module-kit-gateway-")); +const DOMAIN = "pilot.example.lazurio.io"; +const ALENA = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; +const children: ReturnType[] = []; + +const freePort = () => + new Promise((resolve) => { + const server = createServer().listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => + resolve(typeof address === "object" && address ? address.port : 0), + ); + }); + }); +const fail = (message: string): never => { + throw new Error(message); +}; + +try { + const [auth, launchpad, tsPort, pyPort, https] = await Promise.all([ + freePort(), + freePort(), + freePort(), + freePort(), + freePort(), + ]); + const cert = spawnSync("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-subj", + `/CN=*.${DOMAIN}`, + "-addext", + `subjectAltName=DNS:*.${DOMAIN}`, + "-keyout", + join(dir, "key.pem"), + "-out", + join(dir, "cert.pem"), + ]); + if (cert.status !== 0) fail(`openssl: ${cert.stderr}`); + + // Stand-in oauth2-proxy /oauth2/auth: `alena` is a person, `nobody` an + // admitted session whose answer names no person, anything else refused. + Bun.serve({ + hostname: "127.0.0.1", + port: auth, + fetch(request) { + const session = /__Secure-lazurio-workspace=([a-z]+)/.exec( + request.headers.get("cookie") ?? "", + )?.[1]; + if (session === "alena") + return new Response(null, { + status: 202, + headers: { + "X-Auth-Request-User": ALENA, + "X-Auth-Request-Preferred-Username": "alena", + }, + }); + if (session === "nobody") return new Response(null, { status: 202 }); + return new Response(null, { status: 401 }); + }, + }); + // Stand-in Launchpad: every module is ready. + Bun.serve({ + hostname: "127.0.0.1", + port: launchpad, + fetch: () => new Response(null, { status: 204 }), + }); + + const origin = (slug: string) => `https://${slug}.${DOMAIN}`; + // The TypeScript App. + const tsEnv = { + LAZURIO_RUNTIME_LISTENER_APP_HOST: "127.0.0.1", + LAZURIO_RUNTIME_LISTENER_APP_PORT: String(tsPort), + LAZURIO_RUNTIME_LISTENER_APP_EXTERNAL_ORIGIN: origin("ts"), + }; + let reached = 0; + Bun.serve({ + hostname: "127.0.0.1", + port: tsPort, + fetch(request) { + reached++; + return Response.json(appAccess(request, "app", tsEnv)); + }, + }); + // The Python App: the request's header pairs as sent, repeats included. + const python = ` +import dataclasses, json, sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from lazurio_module_kit import app_access +class Pairs: + def __init__(self, pairs): self.pairs = pairs + def items(self): return self.pairs +env = {"LAZURIO_RUNTIME_LISTENER_APP_HOST": "127.0.0.1", "LAZURIO_RUNTIME_LISTENER_APP_PORT": sys.argv[1], + "LAZURIO_RUNTIME_LISTENER_APP_EXTERNAL_ORIGIN": sys.argv[2]} +class App(BaseHTTPRequestHandler): + def do_GET(self): + answer = dataclasses.asdict(app_access(Pairs(list(self.headers.items())), "app", env)) + body = json.dumps(answer).encode() + self.send_response(200); self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))); self.end_headers(); self.wfile.write(body) + def log_message(self, *args): pass +HTTPServer(("127.0.0.1", int(sys.argv[1])), App).serve_forever() +`; + children.push( + spawn( + "uv", + [ + "run", + "--project", + join(kit, "python"), + "python", + "-c", + python, + String(pyPort), + origin("py"), + ], + { stdio: "inherit" }, + ), + ); + + const { workspaceIngress } = (await import( + join(machines, "workloads/workspace-vm/ingress.ts") + )) as { workspaceIngress: (config: unknown, options?: unknown) => string }; + let caddyfile = workspaceIngress( + { + machine: "pilot", + organization: "example", + applications: [{ slug: "launchpad", port: launchpad }], + }, + { authPort: auth }, + ); + caddyfile = caddyfile + .replace( + /admin unix\/\S+/, + `admin off\n https_port ${https}\n http_port ${await freePort()}\n auto_https disable_certs\n storage file_system ${join(dir, "data")}`, + ) + .replace(":443 {", `:${https} {`) + .replaceAll( + "tls /run/lazurio/tls/cert.pem /run/lazurio/tls/key.pem", + `tls ${join(dir, "cert.pem")} ${join(dir, "key.pem")}`, + ) + .replace(/^import \/etc\S*\n/m, "") + .replace( + /# BEGIN local entry[\s\S]*?# END local entry of the Environment browser\n/g, + "", + ); + caddyfile += `\nimport lazurio_module ts.${DOMAIN} 127.0.0.1:${tsPort} ts\nimport lazurio_module py.${DOMAIN} 127.0.0.1:${pyPort} py\n`; + writeFileSync(join(dir, "Caddyfile"), caddyfile); + children.push( + spawn( + caddy, + ["run", "--config", join(dir, "Caddyfile"), "--adapter", "caddyfile"], + { + stdio: ["ignore", "ignore", "inherit"], + env: { + ...process.env, + XDG_DATA_HOME: join(dir, "data"), + XDG_CONFIG_HOME: join(dir, "config"), + }, + }, + ), + ); + + // Asynchronous: the TypeScript App answers from this very process. + const get = async (slug: string, host: string, cookie: string | null) => { + const name = `${slug}.${DOMAIN}`; + const args = [ + "-sk", + "--max-time", + "10", + "--resolve", + `${name}:${https}:127.0.0.1`, + "-H", + `Host: ${host}`, + "-H", + "Sec-Fetch-Mode: navigate", + // A client's own statement: the gateway removes it. + "-H", + "X-Auth-Request-User: 0b6f1c2e-6d3a-4f5b-9c8d-7e6f5a4b3c2d", + "-H", + "X-Lazurio-Entry: app", + "-H", + "X-Lazurio-App-Role: admin", + ...(cookie ? ["-H", `Cookie: __Secure-lazurio-workspace=${cookie}`] : []), + "-w", + "\n%{http_code}", + `https://${name}:${https}/`, + ]; + const curl = Bun.spawn(["curl", ...args], { stdout: "pipe" }); + const out = await new Response(curl.stdout).text(); + await curl.exited; + const cut = out.lastIndexOf("\n"); + return { status: Number(out.slice(cut + 1)), body: out.slice(0, cut) }; + }; + // Wait for Caddy and the Python App. + for (let i = 0; ; i++) { + if ( + (await get("py", `py.${DOMAIN}`, "alena")).status === 200 && + (await get("ts", `ts.${DOMAIN}`, "alena")).status === 200 + ) + break; + if (i > 100) fail("the gateway or an App did not come up"); + await Bun.sleep(200); + } + + const results: string[] = []; + for (const slug of ["ts", "py"]) { + const name = `${slug}.${DOMAIN}`; + for (const host of [ + name, + name.toUpperCase(), + `${name}:443`, + `${name}:${https}`, + `${name}.`, + `${name.toUpperCase()}.:443`, + ]) { + const before = reached; + const first = await get(slug, host, "alena"); + if (first.status !== 200) { + // The gateway answers itself (Caddy refuses a Host that does not + // match the TLS name, 421): the App is never reached. + if (first.status === 200 || (slug === "ts" && reached !== before)) + fail( + `${slug} ${host}: refused with ${first.status} yet reached the App`, + ); + results.push( + `${slug} ${host}: refused by the gateway (${first.status})`, + ); + continue; + } + const person = JSON.parse(first.body); + const role = slug === "ts" ? person.appRole : person.app_role; + if ( + person.entry !== "environment" || + role !== "admin" || + person.person?.subject !== ALENA || + person.source !== "gateway" + ) + fail( + `${slug} ${host}: a person through the gateway reads ${JSON.stringify(person)}`, + ); + const second = await get(slug, host, "nobody"); + if (second.status !== 200) + fail( + `${slug} ${host}: a session without a person got ${second.status}`, + ); + const nobody = JSON.parse(second.body); + const none = slug === "ts" ? nobody.appRole : nobody.app_role; + if ( + nobody.entry !== "unknown" || + none !== null || + nobody.reason !== "no-identity" + ) + fail( + `${slug} ${host}: a session without a person reads ${JSON.stringify(nobody)}`, + ); + const counted = reached; + const refused = await get(slug, host, null); + if (refused.status === 200 || (slug === "ts" && reached !== counted)) + fail( + `${slug} ${host}: a request without a session reached the App (${refused.status})`, + ); + results.push( + `${slug} ${host}: environment/admin with a person, unknown without one, refused without a session`, + ); + } + } + console.log(results.join("\n")); + console.log( + `PASS: through the real gateway (${caddy}), for ${results.length} Host forms of a TypeScript and a Python App: a person is environment/admin from the gateway's answer, an admitted session without a person is unknown with no role, a client's forged statement never counts, no request is local, and without a session nothing reaches the App`, + ); +} finally { + for (const child of children) child.kill("SIGTERM"); + rmSync(dir, { recursive: true, force: true }); + setTimeout(() => process.exit(process.exitCode ?? 0), 200); +} diff --git a/tsconfig.json b/tsconfig.json index cd25e36..7f3c7db 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "types": ["bun"], "skipLibCheck": true }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "proof/**/*.ts"] }