Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/local-api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,13 @@ route("GET", /^\/api\/invites\/([0-9a-f]{64})$/, async (req, res, match) => {
} : null });
});

// For an invitation sent to another address than this account's.
route("GET", /^\/api\/invites\/([0-9a-f]{64})\/hint$/, async (req, res, match) => {
const result = await userAction(req, async (client) => client.query(
"SELECT public.invite_address_hint($1) AS hint", [match[1]]));
send(res, { hint: result.rows[0].hint });
});

// Real email needs LOCAL_API_SEND_EMAIL=yes as well as the Resend settings.
// Without both, the sender is told email isn't set up here (503).
const invitationEmail = invitationEmailSetting(process.env);
Expand Down Expand Up @@ -906,6 +913,26 @@ route("PUT", /^\/api\/preferences$/, async (req, res) => {
send(res, { emailReminders: result.rows[0].email_reminders });
});

// An error report or one page load's speed figures from the web app. The
// database checks each field, and drops a report over the account's limit
// (stored: false).
route("POST", /^\/api\/client-reports$/, async (req, res) => {
const input = await body(req);
const words = (value: unknown) => typeof value === "string" ? value : null;
const figure = (value: unknown, field: string) => {
if (value === undefined || value === null) return null;
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 600_000) {
throw new ApiError(400, `Invalid ${field}`);
}
return value;
};
const result = await userAction(req, async (client) => client.query(
"SELECT public.report_client_event($1, $2, $3, $4, $5, $6, $7, $8, $9) AS stored",
[words(input.kind), words(input.release), words(input.route), words(input.browser), words(input.message),
words(input.stack), figure(input.lcpMs, "lcpMs"), figure(input.inpMs, "inpMs"), figure(input.cls, "cls")]));
send(res, { stored: result.rows[0].stored });
});

function normalizedError(error: unknown): ApiError {
if (error instanceof ApiError) return error;
const postgres = error as { code?: string; message?: string };
Expand Down
61 changes: 61 additions & 0 deletions apps/local-api/tests/client-reports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";

// Migration 0038: the web app's error reports and sampled speed figures are
// kept for a signed-in account, naming pages by pattern and never a record,
// and once an account reaches its limit further reports are dropped rather
// than refused. Creates accounts and records, so it refuses the normal
// application API.
const base = process.env.API_BASE;
if (!base) throw new Error("Set API_BASE to an isolated local test API, not the user's database.");
const url = new URL(base);
if (url.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(url.hostname) || !url.port ||
url.port === "3002" || url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
throw new Error("Client report tests require an isolated loopback API port (not 3002).");
}
const suffix = randomUUID().slice(0, 8);
function session() {
let cookie = "";
return async (method, path, input) => {
const response = await fetch(`${base}/api${path}`, {
method,
headers: { ...(input === undefined ? {} : { "Content-Type": "application/json" }), ...(cookie ? { Cookie: cookie } : {}) },
body: input === undefined ? undefined : JSON.stringify(input),
});
const set = response.headers.get("set-cookie");
if (set) cookie = set.split(";")[0];
return { status: response.status, ...(await response.json()) };
};
}
const ok = (result, status = 200) => { assert.equal(result.status, status, JSON.stringify(result)); return result; };
const reporter = session(), guest = session();
ok(await reporter("POST", "/auth/signup", {
name: "Reporter", email: `client-reports-${suffix}@example.test`, password: "DisposableClientReports-2026!",
}), 201);
const report = (input) => reporter("POST", "/client-reports", input);

const error = { kind: "error", release: "local", route: "/expenses/:id", browser: "chrome",
message: "TypeError: Cannot read properties of undefined", stack: " at render (/assets/index.js:1:2)" };
const speed = { kind: "speed", release: "local", route: "/", browser: "safari", lcpMs: 2300, inpMs: 180, cls: 0.05 };
assert.deepEqual(ok(await report(error)), { status: 200, stored: true });
assert.deepEqual(ok(await report(speed)), { status: 200, stored: true });

// A route that names a record or carries a query is refused, as is anything
// of the wrong kind, rather than becoming a server error.
for (const route of ["/expenses/0f8fe1a5-1234-4abc-9def-0123456789ab", "/expenses?commune=home", "https://evil.example/", "/notes/note1"]) {
assert.equal((await report({ ...error, route })).status, 400, route);
}
for (const input of [{ ...error, kind: 7 }, { ...error, message: undefined }, { ...speed, lcpMs: "fast" }, { ...speed, lcpMs: 1e12 },
{ ...speed, lcpMs: 12.5 }, { ...speed, cls: -1 }, { ...speed, message: "Slow" }, { kind: "speed", release: "local", route: "/", browser: "chrome" }]) {
assert.equal((await report(input)).status, 400, JSON.stringify(input));
}

// Only a signed-in account can report.
assert.equal((await guest("POST", "/client-reports", error)).status, 401);

// 20 an hour: two are kept already, so 18 more are, and the rest are dropped.
const kept = [];
for (let index = 0; index < 20; index += 1) kept.push(ok(await report({ ...error, message: `Error ${index}` })).stored);
assert.deepEqual([kept.filter(Boolean).length, kept.at(-1)], [18, false]);

console.log("Client report tests passed");
61 changes: 61 additions & 0 deletions apps/local-api/tests/invitation-hint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";

// Migration 0038: someone who opens an invitation while signed in with
// another address is told, masked, which address it was sent to, and no one
// else is told anything. Creates accounts and records, so it refuses the
// normal application API.
const base = process.env.API_BASE;
if (!base) throw new Error("Set API_BASE to an isolated local test API, not the user's database.");
const url = new URL(base);
if (url.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(url.hostname) || !url.port ||
url.port === "3002" || url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
throw new Error("Invitation hint tests require an isolated loopback API port (not 3002).");
}
// Letters only, so a commune name that carries it never holds a phone-like run of digits.
const suffix = randomUUID().slice(0, 8).replace(/[0-9]/g, (digit) => "ghijklmnop"[digit]);
const password = "DisposableInvitationHint-2026!";
function session() {
let cookie = "";
return async (method, path, input) => {
const response = await fetch(`${base}/api${path}`, {
method,
headers: { ...(input === undefined ? {} : { "Content-Type": "application/json" }), ...(cookie ? { Cookie: cookie } : {}) },
body: input === undefined ? undefined : JSON.stringify(input),
});
const set = response.headers.get("set-cookie");
if (set) cookie = set.split(";")[0];
return { status: response.status, ...(await response.json()) };
};
}
const ok = (result, status = 200) => { assert.equal(result.status, status, JSON.stringify(result)); return result; };
const owner = session(), invited = session(), other = session(), guest = session();
const users = [];
for (const [index, call] of [owner, invited, other].entries()) {
users.push(ok(await call("POST", "/auth/signup", {
name: ["Owner", "Invited", "Other"][index], email: `${["hint-owner", "hint-invited", "other"][index]}-${suffix}@example.test`, password,
}), 201).user);
}
const [, invitedUser] = users;
const name = `Hint home ${suffix}`;
const id = ok(await owner("POST", "/communes", { name, kind: "household", currency: "GBP" }), 201).id;
const { token } = ok(await owner("POST", `/communes/${id}/invites`, { email: invitedUser.email, role: "member" }), 201);

// Another account sees no invitation, but is told which address to use.
assert.equal(ok(await other("GET", `/invites/${token}`)).invite, null);
assert.deepEqual(ok(await other("GET", `/invites/${token}/hint`)), { status: 200, hint: "h•••@e•••.test" });

// The invited account sees the invitation, and no hint.
assert.equal(ok(await invited("GET", `/invites/${token}`)).invite.communeName, name);
assert.equal(ok(await invited("GET", `/invites/${token}/hint`)).hint, null);

// No hint without the invitation's token, or without a sign-in.
assert.equal(ok(await other("GET", `/invites/${"0".repeat(64)}/hint`)).hint, null);
assert.equal((await other("GET", "/invites/not-a-token/hint")).status, 404);
assert.equal((await guest("GET", `/invites/${token}/hint`)).status, 401);

// Once the invitation is used, it says nothing more.
assert.equal(ok(await invited("POST", `/invites/${token}/accept`, {})).communeId, id);
assert.equal(ok(await other("GET", `/invites/${token}/hint`)).hint, null);

console.log("Invitation hint tests passed");
8 changes: 5 additions & 3 deletions apps/web/index.html
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
<!doctype html>
<html lang="en">
<html lang="en-GB">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/styles/calendar-date.css" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1e2b" />
<meta name="apple-mobile-web-app-title" content="Commune" />
<title>Commune</title>
<meta
name="description"
content="Hub OS for shared spaces. Track splits, settle balances, and keep house money clear."
content="Split bills, settle up, and keep notes and chores in one place with the people you live, work or travel with."
/>
</head>
<body>
Expand Down
Binary file added apps/web/public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/icons/icon-maskable-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions apps/web/public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "Commune",
"short_name": "Commune",
"description": "Split bills, settle up, and keep notes and chores in one place with the people you live, work or travel with.",
"lang": "en-GB",
"dir": "ltr",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f5f1ea",
"theme_color": "#1a1e2b",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
2 changes: 1 addition & 1 deletion apps/web/reference/design-language/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Commune · Design language</title>
<link rel="stylesheet" href="/styles/calendar-date.css" />
<link rel="stylesheet" href="/src/styles/calendar-date.css" />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@600&family=Inter:wght@400;450;500;550;600&display=swap"
rel="stylesheet"
Expand Down
52 changes: 52 additions & 0 deletions apps/web/scripts/render-icons.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Renders Commune's home-screen icons from public/logo-mark.svg with a
// headless Chromium, which draws the mark exactly as a browser does. The PNGs
// it writes are committed; run it again only when the mark changes:
//
// CHROMIUM=/path/to/chrome node apps/web/scripts/render-icons.mjs
//
// (Chrome, Chromium or Playwright's headless_shell all work.) The mark sits on
// the app's navy, the theme colour in index.html. The maskable icon keeps it
// inside the middle 80%, which every launcher's mask shape leaves showing
// (https://www.w3.org/TR/appmanifest/#icon-masks).
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const web = join(dirname(fileURLToPath(import.meta.url)), "..");
const chromium = process.env.CHROMIUM;
if (!chromium) throw new Error("Set CHROMIUM to a Chrome, Chromium or headless_shell binary.");
const navy = "#1a1e2b";
// The mark's drawing, without its own <svg> element.
const drawing = readFileSync(join(web, "public/logo-mark.svg"), "utf8")
.replace(/^[\s\S]*?<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "");
const icons = [
{ file: "public/icons/icon-192.png", size: 192, scale: 1 },
{ file: "public/icons/icon-512.png", size: 512, scale: 1 },
{ file: "public/icons/icon-maskable-512.png", size: 512, scale: 0.8 },
{ file: "public/apple-touch-icon.png", size: 180, scale: 1 },
];

const work = mkdtempSync(join(tmpdir(), "commune-icons-"));
try {
for (const icon of icons) {
const offset = (512 * (1 - icon.scale)) / 2;
const page = join(work, `${icon.size}-${icon.scale}.html`);
writeFileSync(page, `<!doctype html><html><body style="margin:0;background:${navy}">
<svg xmlns="http://www.w3.org/2000/svg" width="${icon.size}" height="${icon.size}" viewBox="0 0 512 512" style="display:block">
<rect width="512" height="512" fill="${navy}"/>
<g transform="translate(${offset} ${offset}) scale(${icon.scale})">${drawing}</g>
</svg></body></html>`);
mkdirSync(dirname(join(web, icon.file)), { recursive: true });
execFileSync(chromium, [
"--headless", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=1",
// Chromium's sandbox cannot start as root, as in a container.
...(process.getuid?.() === 0 ? ["--no-sandbox"] : []),
`--window-size=${icon.size},${icon.size}`, `--screenshot=${join(web, icon.file)}`, pathToFileURL(page).href,
], { stdio: "ignore" });
console.log(`Wrote ${icon.file}`);
}
} finally {
rmSync(work, { recursive: true, force: true });
}
Loading