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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,24 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve
and unlock screens, error notices, the empty sessions list, and the delete
account form. Messages are kept by the service and, with `FEEDBACK_TO`
set, forwarded by email. Nothing from a terminal is attached.
- The statistics dashboard counts people, not only events: unique, new and
returning visitors, CLI machines, installers and viewers, from keyed hashes
of address and browser family that never leave the Worker and are forgotten
after 120 days. A funnel from a first look to a first browser keystroke says
what each step counts, weekly cohorts show who came back, and the accounts
app can add exact account counts and sign-up retention when the two are
linked.
- Clicks on the landing page's Sign up free and Web app links are counted.

### Fixed

- Widened the vault password fields on the vault setup, unlock, and Account
pages to twice their previous width, so a password is no longer typed into a
box sized for the four-letter recovery-key confirmation.
- The web app, CLI, Refstream and platforms documentation pages were counted
as "Not found". Every documentation route, current or versioned, now has its
own page-view target; unknown paths the site answers with the landing page
are counted apart from real 404s, and real 404s are counted at all.

### Changed

Expand Down
4 changes: 4 additions & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ POSTGRES_PASSWORD=
# Set only behind a proxy that replaces X-Forwarded-For
TRUST_PROXY=0

# Optional, 32+ characters. The same value goes on the relay Worker as
# APP_STATS_TOKEN so its statistics dashboard can read account counts.
# STATS_TOKEN=

# Optional invitation email. Defaults to SendGrid.
MAIL_PROVIDER=sendgrid
MAIL_API_KEY=
Expand Down
1 change: 1 addition & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ The client reads `VITE_FIREBASE_*` at build time. The server uses:
| `MAIL_PROVIDER`, `MAIL_API_URL` | Optional non-SendGrid JSON provider |
| `FEEDBACK_TO` | Optional address that feedback sent from the app is forwarded to |
| `TRUST_PROXY` | Set to `1` only behind a trusted proxy |
| `STATS_TOKEN` | Optional, 32+ characters: lets the relay's statistics dashboard read account counts and sign-up cohorts |

See [`.env.example`](.env.example) for the complete development configuration.

Expand Down
28 changes: 28 additions & 0 deletions app/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2581,3 +2581,31 @@ describe("feedback", () => {
expect(other.status).toBe(201);
});
});

describe("account figures for the statistics dashboard", () => {
const TOKEN = "stats-token-with-thirty-two-characters!";

it("does not exist until a token is configured", async () => {
const answer = await call("GET", "/api/stats/accounts", { auth: TOKEN });
expect(answer.status).toBe(404);
});

it("answers only the configured token, with counts and no identifiers", async () => {
handle = createApp({ store, verifyIdToken: verifyIdToken as never, allowedOrigins: [ORIGIN], statsToken: TOKEN });
/* One person signs in, which creates their organization and marks the day. */
expect((await call("GET", "/api/org", { auth: await idToken() })).status).toBe(200);

expect((await call("GET", "/api/stats/accounts?range=7d")).status).toBe(401);
expect((await call("GET", "/api/stats/accounts?range=7d", { auth: "stats-token-with-thirty-two-characters?" })).status).toBe(401);
expect((await call("GET", "/api/stats/accounts?range=7d", { auth: await idToken() })).status).toBe(401);

const answer = await call("GET", "/api/stats/accounts?range=7d", { auth: TOKEN });
expect(answer.status).toBe(200);
expect(answer.body).toMatchObject({ total: 1, newInRange: 1, activeInRange: 1 });
expect(answer.body.newByDay).toHaveLength(90);
expect(answer.body.cohorts).toHaveLength(1);
expect(answer.body.cohorts[0].size).toBe(1);
expect(JSON.stringify(answer.body)).not.toContain("uid-1");
expect(JSON.stringify(answer.body)).not.toContain("ana@example.com");
});
});
26 changes: 25 additions & 1 deletion app/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import { recordAudit, assignSession, auditCsv, SEALED_KINDS } from "./routes/aud
import { addComment, inbox, notifyAssigned, notifySessionStarted } from "./routes/social";
import { deleteAccount } from "./routes/account";
import { submitFeedback } from "./routes/feedback";
import { accountStats, isStatsRange } from "./routes/stats";
import { timingSafeEqual } from "node:crypto";
import { callerAddress, rateLimiter } from "./lib/rate-limit";
import { logMailer, type Mailer } from "./lib/mail";

Expand Down Expand Up @@ -76,6 +78,12 @@ export interface AppOptions {
* store only, which is still the record; the mail is for whoever reads it.
*/
feedbackTo?: string;
/**
* Lets the statistics dashboard on the relay read account figures: counts
* and sign-up cohorts, never a person. Absent means the route does not
* exist. At least 32 characters; see readConfig.
*/
statsToken?: string;
/**
* Serves the built client for anything that is not an API route. Present
* only in a deployment that serves the app and the API together; in
Expand Down Expand Up @@ -364,7 +372,10 @@ export function createApp(options: AppOptions) {
async function requireMember(request: IncomingMessage, inviteId?: string) {
const identity = await requireUser(request);
if (!identity) return null;
return (await ensureMembership(store, identity, inviteId))?.membership ?? null;
const membership = (await ensureMembership(store, identity, inviteId))?.membership ?? null;
/* A day with a request on it is a day the account was active. */
if (membership) await store.touchMembership(membership.uid);
return membership;
}

/* The CLI authenticates with an opaque access token issued by this service. */
Expand Down Expand Up @@ -1460,6 +1471,19 @@ export function createApp(options: AppOptions) {
return send(response, 201, { feedback: { id: result.value.id, at: result.value.at } });
}

/* ---- Account figures for the statistics dashboard ---- */

if (route === "GET /api/stats/accounts") {
const expected = options.statsToken;
if (!expected) return send(response, 404, { error: "not found" });
const presented = bearer(request);
const matches = presented.length === expected.length &&
timingSafeEqual(Buffer.from(presented), Buffer.from(expected));
if (!matches) return send(response, 401, { error: "sign in first" });
const range = url.searchParams.get("range");
return send(response, 200, accountStats(await store.accountActivity(), isStatsRange(range) ? range : "7d"));
}

/* ---- Inbox ---- */

if (route === "GET /api/notifications") {
Expand Down
1 change: 1 addition & 0 deletions app/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const server = createAccountsServer({
trustProxy: config.trustProxy,
mailer: createMailer(config.mail),
feedbackTo: config.feedbackTo,
statsToken: config.statsToken,
serveClient: config.clientDir ? staticFiles(config.clientDir) : undefined,
relay: forward ?? undefined,
sessionLiveness: config.relayUrl ? relaySessionLiveness(config.relayUrl) : undefined,
Expand Down
12 changes: 12 additions & 0 deletions app/server/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export interface Config {
* in the database only, which is still the record.
*/
feedbackTo?: string;
/**
* The bearer token the relay's statistics dashboard presents for account
* figures. Absent means that route does not exist.
*/
statsToken?: string;
}

export class ConfigError extends Error {}
Expand Down Expand Up @@ -133,6 +138,12 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): Config {
throw new ConfigError("set DATABASE_URL: the file store cannot back a deployment");
}

const statsToken = env.STATS_TOKEN?.trim() || undefined;
/* A short token is a guessable one, and this one opens business figures. */
if (statsToken !== undefined && statsToken.length < 32) {
throw new ConfigError("STATS_TOKEN must be at least 32 characters");
}

const clientDir = env.CLIENT_DIR?.trim() || undefined;
const relayUrl = env.RELAY_URL?.trim() || undefined;
if (relayUrl) {
Expand Down Expand Up @@ -175,5 +186,6 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): Config {
from: env.MAIL_FROM?.trim() || undefined,
},
feedbackTo: env.FEEDBACK_TO?.trim() || undefined,
statsToken,
};
}
17 changes: 17 additions & 0 deletions app/server/lib/migrations/012_account_activity.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- The days on which an account used the app, so the statistics dashboard can
-- say how many accounts are active and how many come back after signing up.
--
-- One row per account per day and nothing else: no route, no action, no
-- address. last_seen_at on the membership is the same fact at finer grain,
-- and is what keeps the write cheap: a request only touches these when the
-- membership has not been touched for a while. Both are deleted with the
-- account; the days are dropped after 400 days regardless.
ALTER TABLE memberships ADD COLUMN IF NOT EXISTS last_seen_at BIGINT;

CREATE TABLE IF NOT EXISTS account_activity (
uid TEXT NOT NULL,
day BIGINT NOT NULL,
PRIMARY KEY (uid, day)
);

CREATE INDEX IF NOT EXISTS account_activity_day ON account_activity (day);
5 changes: 5 additions & 0 deletions app/server/lib/orgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export interface Membership {
name: string;
role: Role;
joinedAt: number;
/**
* When this account last used the app, to the hour. Moved by
* Store.touchMembership; see the account_activity migration for why.
*/
lastSeenAt?: number;
/**
* This person's browser key, published so colleagues can seal a session
* password to them. Absent until they have signed in somewhere.
Expand Down
37 changes: 37 additions & 0 deletions app/server/lib/store-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ function feedback(overrides: Partial<Feedback> = {}): Feedback {

const TABLES = [
"feedback",
"account_activity",
"deleted_accounts",
"account_keys",
"session_key_shares",
Expand Down Expand Up @@ -906,6 +907,42 @@ for (const implementation of implementations) {
});
});

describe("account activity", () => {
const day = 24 * 60 * 60_000;
const noon = 10 * day + 12 * 60 * 60_000;

it("marks a day once and moves last seen at most once an hour", async () => {
await store.putOrganization(organization());
await store.putMembership(membership());
await store.touchMembership("uid-1", noon);
await store.touchMembership("uid-1", noon + 10 * 60_000);
expect((await store.membershipOf("uid-1"))?.lastSeenAt).toBe(noon);
await store.touchMembership("uid-1", noon + 2 * 60 * 60_000);
expect((await store.membershipOf("uid-1"))?.lastSeenAt).toBe(noon + 2 * 60 * 60_000);
await store.touchMembership("uid-1", noon + day);
await store.touchMembership("nobody", noon);
expect(await store.accountActivity()).toEqual([{ joinedAt: 1000, days: [10 * day, 11 * day] }]);
});

it("keeps the days through a membership rewrite and drops them with the account", async () => {
await store.putOrganization(organization());
await store.putMembership(membership());
await store.touchMembership("uid-1", noon);
await store.putMembership(membership({ name: "Ana R." }));
expect((await store.accountActivity())[0].days).toEqual([10 * day]);
await store.deleteAccount("uid-1", { orgId: "org_1", dissolve: true }, noon + 1);
expect(await store.accountActivity()).toEqual([]);
});

it("forgets days older than the memory window when purging", async () => {
await store.putOrganization(organization());
await store.putMembership(membership());
await store.touchMembership("uid-1", noon);
await store.purgeExpired(noon + 401 * day);
expect((await store.accountActivity())[0].days).toEqual([]);
});
});

describe("claiming an organization on first sight", () => {
/*
* Signing in fires several requests at once. On a new account none of
Expand Down
35 changes: 34 additions & 1 deletion app/server/lib/store-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { randomBytes } from "node:crypto";
import { dirname, join } from "node:path";
import type { Invite, Membership, Organization, Role } from "./orgs";
import {
ACCOUNT_ACTIVITY_MEMORY_MS,
DAY_MS,
DELETED_ACCOUNT_MEMORY_MS,
DELETED_ACTOR_EMAIL,
type AccountDeletion,
Expand All @@ -11,6 +13,7 @@ import {
type Store,
} from "./store";
import type {
AccountActivity,
AccountKey,
AgentCommand,
AuditEvent,
Expand Down Expand Up @@ -69,6 +72,7 @@ interface Shape {
feedback: Feedback[];
accountKeys: AccountKey[];
deletedAccounts: { uid: string; deletedAt: number }[];
accountActivity: { uid: string; day: number }[];
teamKeys: TeamKey[];
teamKeyShares: TeamKeyShare[];
}
Expand All @@ -77,7 +81,7 @@ const EMPTY: Shape = {
codes: [], tokens: [], sessions: [], commands: [],
organizations: [], memberships: [], invites: [], audit: [],
comments: [], notifications: [], feedback: [], accountKeys: [], deletedAccounts: [],
teamKeys: [], teamKeyShares: [],
accountActivity: [], teamKeys: [], teamKeyShares: [],
};

/**
Expand Down Expand Up @@ -143,6 +147,7 @@ export class MemoryStore implements Store {
feedback: parsed.feedback ?? [],
accountKeys: parsed.accountKeys ?? [],
deletedAccounts: parsed.deletedAccounts ?? [],
accountActivity: parsed.accountActivity ?? [],
teamKeys: parsed.teamKeys ?? [],
teamKeyShares: parsed.teamKeyShares ?? [],
};
Expand Down Expand Up @@ -337,6 +342,7 @@ export class MemoryStore implements Store {
if (invite.acceptedBy === uid) delete invite.email;
}
data.accountKeys = data.accountKeys.filter((entry) => entry.uid !== uid);
data.accountActivity = data.accountActivity.filter((entry) => entry.uid !== uid);
data.comments = data.comments.filter((entry) => entry.authorUid !== uid);
data.notifications = data.notifications.filter(
(entry) => entry.uid !== uid && entry.actorUid !== uid,
Expand Down Expand Up @@ -814,6 +820,9 @@ export class MemoryStore implements Store {
}

async purgeExpired(now = Date.now()): Promise<void> {
this.data.accountActivity = this.data.accountActivity.filter(
(entry) => entry.day >= now - ACCOUNT_ACTIVITY_MEMORY_MS,
);
const before = this.data.codes.length;
this.data.codes = this.data.codes.filter((entry) => entry.expiresAt > now);
/* Finished commands are only kept long enough to be reported back. */
Expand Down Expand Up @@ -860,6 +869,30 @@ export class MemoryStore implements Store {
.slice(0, limit);
}

/* ---- Account activity ---- */

async touchMembership(uid: string, now = Date.now(), resolutionMs = 60 * 60_000): Promise<void> {
const membership = this.data.memberships.find((entry) => entry.uid === uid);
if (!membership) return;
if (membership.lastSeenAt !== undefined && now - membership.lastSeenAt < resolutionMs) return;
membership.lastSeenAt = now;
const day = Math.floor(now / DAY_MS) * DAY_MS;
if (!this.data.accountActivity.some((entry) => entry.uid === uid && entry.day === day)) {
this.data.accountActivity.push({ uid, day });
}
this.flush();
}

async accountActivity(): Promise<AccountActivity[]> {
return this.data.memberships.map((membership) => ({
joinedAt: membership.joinedAt,
days: this.data.accountActivity
.filter((entry) => entry.uid === membership.uid)
.map((entry) => entry.day)
.sort((left, right) => left - right),
}));
}

async tokensForImport(): Promise<CliToken[]> {
return this.data.tokens;
}
Expand Down
Loading
Loading