From 174615e843f3a8d851e3c84b4155021fb0b2d9dd Mon Sep 17 00:00:00 2001 From: Marten Klitzke Date: Wed, 7 Oct 2026 17:24:02 +0200 Subject: [PATCH 1/3] feat(rsi): read the org admin pages and save and publish an org draft --- lib/rsi.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/lib/rsi.ts b/lib/rsi.ts index e3e0aae..5851bff 100644 --- a/lib/rsi.ts +++ b/lib/rsi.ts @@ -4,6 +4,7 @@ export type RSIApiParams = { url: string; payload: any; rsiToken: string; + headers?: Record; }; export function fetchRSIApi(params: RSIApiParams) { @@ -14,6 +15,7 @@ export function fetchRSIApi(params: RSIApiParams) { Accept: "*/*", "Accept-Language": "en-GB,en-US;q=0.9,en;q=0.8", "X-Rsi-Token": params.rsiToken, + ...params.headers, }, credentials: "include", body: JSON.stringify(params.payload), @@ -106,3 +108,55 @@ export function updateBio(token: string, bio: string) { rsiToken: token, }); } + +// The org admin pages: `content` holds the draft's raw text in its form, and +// `preview` renders the draft as the public page will once it is published. +export function fetchOrgAdminPage( + token: string, + sid: string, + page: "content" | "preview" +) { + return fetch(`${RSI_BASE_URL}/en/orgs/${encodeURIComponent(sid)}/admin/${page}`, { + method: "GET", + headers: { + ...HTML_PAGE_HEADERS, + "X-Rsi-Token": token, + }, + credentials: "include", + cache: "no-store", + }); +} + +export function fetchOrgPage(sid: string) { + return fetch(`${RSI_BASE_URL}/en/orgs/${encodeURIComponent(sid)}`, { + method: "GET", + credentials: "omit", + cache: "no-store", + }); +} + +const ORG_ADMIN_HEADERS = { "X-Requested-With": "XMLHttpRequest" }; + +export function saveOrgDraft( + token: string, + sid: string, + field: string, + value: string +) { + return fetchRSIApi({ + url: `${RSI_BASE_URL}/api/orgs/saveDraft`, + payload: { symbol: sid, [field]: value }, + rsiToken: token, + headers: ORG_ADMIN_HEADERS, + }); +} + +// Publishes every pending change in the org's draft, not one field. +export function publishOrgDraft(token: string, sid: string) { + return fetchRSIApi({ + url: `${RSI_BASE_URL}/api/orgs/publishDraft`, + payload: { symbol: sid }, + rsiToken: token, + headers: ORG_ADMIN_HEADERS, + }); +} From 588d1ac2675e143ec8612e186c6b9f0600128729 Mon Sep 17 00:00:00 2001 From: Marten Klitzke Date: Wed, 7 Oct 2026 17:24:02 +0200 Subject: [PATCH 2/3] feat(org): read an org's raw draft field, its edit rights and pending draft changes --- __tests__/org.test.ts | 88 +++++++++++++++++++++++++++++++++++++++++++ lib/bio.ts | 10 +++-- lib/org.ts | 63 +++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 __tests__/org.test.ts create mode 100644 lib/org.ts diff --git a/__tests__/org.test.ts b/__tests__/org.test.ts new file mode 100644 index 0000000..8e23e0e --- /dev/null +++ b/__tests__/org.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { + contentBlocks, + hasPendingChanges, + isAccessDenied, + parseDraftField, +} from "@/lib/org"; + +// Trimmed from a live org page. +const orgPage = (history: string, manifesto = "Our manifesto.") => ` +Maru Inc. [MARU] - Organizations +

Welcome aboard.

+
+

History

+
${history}
+
+
+

Manifesto

+

${manifesto}

+
`; + +const contentPage = (history: string) => ` +Description - Admin - Maru Inc. [MARU] + +`; + +describe("isAccessDenied", () => { + it("reads RSI's page for an account without rights", () => { + expect(isAccessDenied("Access denied - Roberts Space Industries")).toBe(true); + }); + + it("leaves the admin page alone", () => { + expect(isAccessDenied(contentPage("History"))).toBe(false); + }); +}); + +describe("parseDraftField", () => { + it("reads the field's raw text, formatting included", () => { + expect( + parseDraftField(contentPage("h1. Our story\n\n*bold* & more"), "history") + ).toBe("h1. Our story\n\n*bold* & more"); + }); + + it("refuses a page without the field", () => { + expect(parseDraftField("Something else", "history")).toBeNull(); + }); +}); + +describe("contentBlocks", () => { + it("reads every text block as plain text without tokens", () => { + expect( + contentBlocks( + orgPage( + '

Our board.

\n\n

FLEETYARDS-ABCDEFGHIJ

' + ) + ) + ).toEqual(["Welcome aboard.", "Our board.", "Our manifesto."]); + }); + + it("refuses a page without text blocks", () => { + expect(contentBlocks("Access denied")).toBeNull(); + }); +}); + +describe("hasPendingChanges", () => { + it("sees nothing pending when only tokens differ", () => { + expect( + hasPendingChanges( + orgPage("

Our board.

"), + orgPage("

Our board.

FLEETYARDS-ABCDEFGHIJ

") + ) + ).toBe(false); + }); + + it("sees another officer's unpublished edit", () => { + expect( + hasPendingChanges( + orgPage("

Our board.

", "A new manifesto."), + orgPage("

Our board.

") + ) + ).toBe(true); + }); + + it("refuses to compare a page it cannot read", () => { + expect(hasPendingChanges("", orgPage("

x

"))).toBeNull(); + }); +}); diff --git a/lib/bio.ts b/lib/bio.ts index c91cc29..f24edd5 100644 --- a/lib/bio.ts +++ b/lib/bio.ts @@ -13,7 +13,7 @@ const ENTITIES: Record = { // Null for an entity it does not know: written back as it stands, it would // show up in the bio as literal text. -function decodeEntities(text: string): string | null { +export function decodeEntities(text: string): string | null { let unknown = false; const decoded = text.replace( @@ -72,11 +72,15 @@ export function parseBio(html: string): string | null { export class BioTooLongError extends Error {} -export function withToken(bio: string, token: string) { +export function withToken( + bio: string, + token: string, + maxLength = BIO_MAX_LENGTH +) { if (bio.includes(token)) return { bio, added: false }; const next = bio ? `${bio}\n\n${token}` : token; - if (next.length > BIO_MAX_LENGTH) throw new BioTooLongError(); + if (next.length > maxLength) throw new BioTooLongError(); return { bio: next, added: true }; } diff --git a/lib/org.ts b/lib/org.ts new file mode 100644 index 0000000..e51e42b --- /dev/null +++ b/lib/org.ts @@ -0,0 +1,63 @@ +import { decodeEntities } from "./bio"; + +// The org text field the token goes into: the history has no length limit, +// unlike the 300-character introduction, and is rarely the org's front page. +export const ORG_VERIFICATION_FIELD = "history"; + +// As the site and RSI print an org's SID: up to ten capitals and digits. +export const SID_PATTERN = /^[A-Z0-9]{1,10}$/; + +const TOKEN_IN_TEXT = /FLEETYARDS-[A-Z0-9]{10}/g; + +// Without content rights RSI still answers 200, with a page titled so. +export function isAccessDenied(html: string) { + return /\s*Access denied/i.test(html); +} + +// A draft field exactly as an officer typed it: the admin content page renders +// it into its textarea, only escaped. HTML drops one newline straight after +// the opening tag, so the field's text starts after it. +export function parseDraftField(html: string, field: string): string | null { + const textarea = html.match( + new RegExp( + `<textarea\\b[^>]*\\bname="${field}"[^>]*>([\\s\\S]*?)</textarea>` + ) + ); + if (!textarea) return null; + + return decodeEntities(textarea[1]!.replace(/^\r?\n/, "")); +} + +// The org's text blocks as a reader sees them, for comparing two renderings +// of the page: tags and every FleetYards token dropped, whitespace collapsed. +// Null when the page has none, which is markup this cannot read. +export function contentBlocks(html: string): string[] | null { + const blocks = [ + ...html.matchAll(/<div class="markitup-text">([\s\S]*?)<\/div>/g), + ].map((match) => + match[1]! + .replace(/<[^>]*>/g, "") + .replace(/&[^;\s]+;/g, (entity) => decodeEntities(entity) ?? entity) + .replace(TOKEN_IN_TEXT, "") + .replace(/\s+/g, " ") + .trim() + ); + + return blocks.length > 0 ? blocks : null; +} + +// Whether the org's draft holds changes besides FleetYards tokens: RSI +// publishes the whole draft at once, so writing then would publish them too. +export function hasPendingChanges( + previewHtml: string, + publicHtml: string +): boolean | null { + const draft = contentBlocks(previewHtml); + const live = contentBlocks(publicHtml); + if (!draft || !live) return null; + + return ( + draft.length !== live.length || + draft.some((block, index) => block !== live[index]) + ); +} From 71de011e5b6bc1ea9aa4096bc4dfbf044d9b5099 Mon Sep 17 00:00:00 2001 From: Marten Klitzke <marten@klitzke.xyz> Date: Wed, 7 Oct 2026 17:24:02 +0200 Subject: [PATCH 3/3] feat: org-verify-write and org-verify-remove actions for fleet verification --- __tests__/message-handler.test.ts | 133 ++++++++++++++++++++++++++++++ lib/message-handler.ts | 117 ++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) diff --git a/__tests__/message-handler.test.ts b/__tests__/message-handler.test.ts index 6da73e3..14ccc0b 100644 --- a/__tests__/message-handler.test.ts +++ b/__tests__/message-handler.test.ts @@ -32,6 +32,8 @@ describe("onMessage", () => { "syncBuybackPricing", "verify-write", "verify-remove", + "org-verify-write", + "org-verify-remove", ], }, }); @@ -504,6 +506,137 @@ describe("onMessage verify actions", () => { }); }); +describe("onMessage org verify actions", () => { + const verificationToken = "FLEETYARDS-ABCDEFGHIJ"; + + const orgPage = (history: string, manifesto = "Ours.") => + `<div class="markitup-text"><p>Intro.</p></div><div class="markitup-text">${history}</div><div class="markitup-text"><p>${manifesto}</p></div>`; + + const contentPage = (history: string) => + `<title>Description - Admin`; + + type Rsi = { + content?: string; + preview?: string; + live?: string; + save?: unknown; + publish?: unknown; + }; + + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status }); + + const mockRsi = ({ + content = contentPage("Our board."), + preview = orgPage("

Our board.

"), + live = orgPage("

Our board.

"), + save = { success: 1 }, + publish = { success: 1 }, + }: Rsi = {}) => + vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + const target = String(url); + if (target.endsWith("/en/orgs/MARU/admin/content")) return new Response(content); + if (target.endsWith("/en/orgs/MARU/admin/preview")) return new Response(preview); + if (target.endsWith("/en/orgs/MARU")) return new Response(live); + if (target.endsWith("/api/orgs/saveDraft")) return json(save); + if (target.endsWith("/api/orgs/publishDraft")) return json(publish); + throw new Error(`unexpected ${target}`); + }); + + const send = async (message: object) => { + const sendResponse = vi.fn(); + await onMessage( + JSON.stringify(message), + sendResponse, + vi.fn().mockResolvedValue("rsi-token"), + "1.2.3" + ); + return JSON.parse(sendResponse.mock.calls[0]![0]); + }; + + const posted = (fetch: ReturnType, path: string) => + fetch.mock.calls + .filter(([url]) => String(url).endsWith(path)) + .map(([, init]) => JSON.parse(String(init?.body))); + + it("appends the token to the history and publishes it", async () => { + const fetch = mockRsi(); + + const result = await send({ action: "org-verify-write", sid: "MARU", token: verificationToken }); + + expect(result).toEqual({ + code: 200, + action: "org-verify-write", + payload: { sid: "MARU", changed: true }, + }); + expect(posted(fetch, "/api/orgs/saveDraft")).toEqual([ + { symbol: "MARU", history: `Our board.\n\n${verificationToken}` }, + ]); + expect(posted(fetch, "/api/orgs/publishDraft")).toEqual([{ symbol: "MARU" }]); + }); + + it("answers 403 for an account without rights on the org", async () => { + const fetch = mockRsi({ content: "Access denied - Roberts Space Industries" }); + + const result = await send({ action: "org-verify-write", sid: "MARU", token: verificationToken }); + + expect(result.code).toBe(403); + expect(posted(fetch, "/api/orgs/saveDraft")).toEqual([]); + }); + + it("answers 409 while another edit waits in the draft", async () => { + const fetch = mockRsi({ preview: orgPage("

Our board.

", "Half done.") }); + + const result = await send({ action: "org-verify-write", sid: "MARU", token: verificationToken }); + + expect(result.code).toBe(409); + expect(posted(fetch, "/api/orgs/saveDraft")).toEqual([]); + expect(posted(fetch, "/api/orgs/publishDraft")).toEqual([]); + }); + + it("answers 422 for org pages it cannot read", async () => { + const fetch = mockRsi({ preview: "" }); + + const result = await send({ action: "org-verify-write", sid: "MARU", token: verificationToken }); + + expect(result.code).toBe(422); + expect(posted(fetch, "/api/orgs/saveDraft")).toEqual([]); + }); + + it("does not publish when saving the draft was refused", async () => { + const fetch = mockRsi({ save: { success: 0, msg: "ErrCsrf" } }); + + const result = await send({ action: "org-verify-write", sid: "MARU", token: verificationToken }); + + expect(result.code).toBe(502); + expect(posted(fetch, "/api/orgs/publishDraft")).toEqual([]); + }); + + it("refuses an SID that is not one", async () => { + const fetch = vi.spyOn(globalThis, "fetch"); + + const result = await send({ action: "org-verify-write", sid: "../x", token: verificationToken }); + + expect(result.code).toBe(400); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("removes the token it appended and publishes again", async () => { + const fetch = mockRsi({ + content: contentPage(`Our board.\n\n${verificationToken}`), + live: orgPage(`

Our board.

${verificationToken}

`), + }); + + const result = await send({ action: "org-verify-remove", sid: "MARU", token: verificationToken }); + + expect(result.payload).toEqual({ sid: "MARU", changed: true }); + expect(posted(fetch, "/api/orgs/saveDraft")).toEqual([ + { symbol: "MARU", history: "Our board." }, + ]); + expect(posted(fetch, "/api/orgs/publishDraft")).toHaveLength(1); + }); +}); + describe("handleResponse", () => { it("posts message for fleetyards.net origin", () => { const postMessage = vi.fn(); diff --git a/lib/message-handler.ts b/lib/message-handler.ts index 12f5f4f..4ff3236 100644 --- a/lib/message-handler.ts +++ b/lib/message-handler.ts @@ -7,7 +7,18 @@ import { fetchStorePricing, fetchCitizenPage, updateBio, + fetchOrgAdminPage, + fetchOrgPage, + saveOrgDraft, + publishOrgDraft, } from "./rsi"; +import { + ORG_VERIFICATION_FIELD, + SID_PATTERN, + hasPendingChanges, + isAccessDenied, + parseDraftField, +} from "./org"; import { BioTooLongError, VERIFICATION_TOKEN_PATTERN, @@ -28,9 +39,12 @@ export const SUPPORTED_ACTIONS = [ "syncBuybackPricing", "verify-write", "verify-remove", + "org-verify-write", + "org-verify-remove", ] as const; type VerifyAction = "verify-write" | "verify-remove"; +type OrgVerifyAction = "org-verify-write" | "org-verify-remove"; // RSI's APIs answer some refusals with a 200 and `success: 0` in the body. async function reportsSuccess(response: Response) { @@ -147,6 +161,84 @@ async function storePricing(token: string) { }; } +// Writes the token into the org's history and publishes it, for the org the +// signed-in account can edit. Only while nothing else waits in the org's +// draft: publishing takes the whole draft live, another officer's half-done +// edit included. +async function verifyOrg( + action: OrgVerifyAction, + sid: unknown, + verificationToken: unknown, + rsiToken: string +) { + if ( + typeof verificationToken !== "string" || + !VERIFICATION_TOKEN_PATTERN.test(verificationToken) + ) { + return { code: 400, action, error: "Invalid verification token" }; + } + if (typeof sid !== "string" || !SID_PATTERN.test(sid)) { + return { code: 400, action, error: "Invalid SID" }; + } + + const content = await fetchOrgAdminPage(rsiToken, sid, "content"); + if (!content.ok) { + return { code: content.status, action, error: "Org unreadable", payload: { sid } }; + } + + const contentHtml = await content.text(); + if (isAccessDenied(contentHtml)) { + return { code: 403, action, error: "No rights for this org", payload: { sid } }; + } + + const draft = parseDraftField(contentHtml, ORG_VERIFICATION_FIELD); + if (draft === null) { + return { code: 422, action, error: "Org unreadable", payload: { sid } }; + } + + const [preview, live] = await Promise.all([ + fetchOrgAdminPage(rsiToken, sid, "preview"), + fetchOrgPage(sid), + ]); + const pending = + preview.ok && live.ok + ? hasPendingChanges(await preview.text(), await live.text()) + : null; + if (pending === null) { + return { code: 422, action, error: "Org unreadable", payload: { sid } }; + } + if (pending) { + return { code: 409, action, error: "Unpublished changes", payload: { sid } }; + } + + let next: string; + let changed: boolean; + if (action === "org-verify-write") { + ({ bio: next, added: changed } = withToken(draft, verificationToken, Infinity)); + } else { + ({ bio: next, removed: changed } = withoutToken(draft, verificationToken)); + } + + if (changed) { + for (const request of [ + () => saveOrgDraft(rsiToken, sid, ORG_VERIFICATION_FIELD, next), + () => publishOrgDraft(rsiToken, sid), + ]) { + const response = await request(); + if (!response.ok || !(await reportsSuccess(response))) { + return { + code: response.ok ? 502 : response.status, + action, + error: "Org update failed", + payload: { sid }, + }; + } + } + } + + return { code: 200, action, payload: { sid, changed } }; +} + export async function onMessage( rawMessage: string, sendResponse: SendResponse, @@ -185,6 +277,31 @@ export async function onMessage( } ); + sendResponse(JSON.stringify(result)); + } + } else if ( + message?.action == "org-verify-write" || + message?.action == "org-verify-remove" + ) { + console.info("FY Sync: Updating Org"); + + const token = await getToken(); + if (!token) { + sendResponse( + JSON.stringify({ code: 401, action: message.action, error: "No RSI session" }) + ); + } else { + const result = await verifyOrg( + message.action, + message.sid, + message.token, + token + ).catch((error) => { + console.error("FY Sync: Org update failed", error); + + return { code: 500, action: message.action, error: "Org update failed" }; + }); + sendResponse(JSON.stringify(result)); } } else if (message?.action == "identify") {