Skip to content
Open
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
20 changes: 20 additions & 0 deletions docs/superpowers/specs/2026-09-01-lifeboard-architecture-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,23 @@ expensive, defensible parts and should not be discovered late inside a plan name
- **The canvas reuse** is verified, not assumed (§2), and remains the cheapest part of the build.
- **The trust ladder** (§9a) is the first version of that idea that follows *derivation* rather than
claimed confidence, which makes it auditable.

## Resolution of B1 (2026-09-02)

**Finding B1** — nightly mail triage cannot authenticate, because Gmail is signed in from the
browser with PKCE and the token lives on the device that signed in, while triage was to run on the
bridge.

**Resolved by moving triage into the client.** `src/mail/triage.ts` runs a catch-up pass over
everything since it last ran, in the browser, using the token the browser already holds. Opening the
app in the morning triages the night's mail. The bridge may *ask* for a pass while a client is open
(Plan 8), but it never holds Google credentials.

The alternative — handing a long-lived refresh token to a separate local process so it can read mail
unattended — was rejected. It is a real escalation of what the family has agreed to: the difference
between "this app can read my mail while I am looking at it" and "this machine can read my mail
forever", and it moves a credential into a process with a different lifetime and no UI to revoke it.

**The cost, stated plainly:** with the iPad closed for a week, nothing is triaged until it opens.
That is a worse story than an always-on assistant, and it is the honest one for an app with no
backend. It is the same trade every other capability in the design makes.
38 changes: 38 additions & 0 deletions packages/lifeboard/src/contacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Who the family can be told about. Kept locally rather than synced: a contact list is the kind of
// thing people expect to stay on the device it was typed into.

import type { Row } from "./log.js";
import type { Store } from "./store.js";
import { add, update, type Base, type By } from "./types.js";

export interface Contact extends Base {
name: string;
phone?: string;
email?: string;
relation?: string;
}

export const listContacts = (store: Store, spaces?: string[]): Row<Contact>[] =>
store.list<Contact>("contact", spaces ? { space: spaces } : {});

export async function addContact(store: Store, by: By, fields: Omit<Contact, keyof Base> & { space?: string }): Promise<void> {
await store.apply([add("contact", { space: fields.space ?? "shared", ...fields })], by);
}

export async function updateContact(store: Store, by: By, id: string, patch: Partial<Contact>): Promise<void> {
await store.apply([update("contact", id, patch)], by);
}

/** Best match for a name typed in a hurry. Exact first, then a prefix, then a contained word —
* and nothing at all when two people match equally well, because sending a message to the wrong
* person is the failure this whole path exists to avoid. */
export function findContact(contacts: readonly Contact[], query: string): Contact | null {
const q = query.trim().toLowerCase();
if (!q) return null;
const exact = contacts.filter((c) => c.name.toLowerCase() === q);
if (exact.length === 1) return exact[0];
const prefix = contacts.filter((c) => c.name.toLowerCase().startsWith(q));
if (prefix.length === 1) return prefix[0];
const contains = contacts.filter((c) => c.name.toLowerCase().includes(q));
return contains.length === 1 ? contains[0] : null;
}
121 changes: 121 additions & 0 deletions packages/lifeboard/src/google/calendar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// The family calendar.
//
// Two things a calendar has to get right or it is worse than no calendar:
//
// Recurrence. `singleEvents=true` asks Google to expand a repeating event into its instances, so
// "swimming every Tuesday" arrives as the Tuesdays rather than as a rule this app would have to
// interpret. Getting recurrence wrong means missing a school run.
//
// Time zones and DST. An all-day event has a `date` and no zone and must never be turned into an
// instant; a timed event has an offset in its `dateTime` and is an instant. The day an event
// belongs to is computed from the local calendar day, never by adding 24-hour blocks — across a
// DST boundary a day is 23 or 25 hours long.

import type { Base } from "../types.js";

export interface CalendarEvent extends Base {
/** Google's event id, so a second sync updates rather than duplicates. */
externalId?: string;
title: string;
/** ISO instant for a timed event, YYYY-MM-DD for an all-day one. */
start: string;
end: string;
allDay: boolean;
location?: string;
notes?: string;
attendees?: string[];
cancelled?: boolean;
calendarId?: string;
}

export interface Range {
from: string;
to: string;
}

export interface CalendarDeps {
accessToken: string;
fetch?: typeof fetch;
calendarId?: string;
}

const API = "https://www.googleapis.com/calendar/v3";

export async function listEvents(range: Range, deps: CalendarDeps): Promise<Omit<CalendarEvent, keyof Base>[]> {
const f = deps.fetch ?? fetch;
const cal = deps.calendarId ?? "primary";
const u = new URL(`${API}/calendars/${encodeURIComponent(cal)}/events`);
u.searchParams.set("timeMin", new Date(range.from).toISOString());
u.searchParams.set("timeMax", new Date(range.to).toISOString());
// Expanded here rather than interpreted here.
u.searchParams.set("singleEvents", "true");
u.searchParams.set("orderBy", "startTime");
u.searchParams.set("maxResults", "250");

const res = await f(u.toString(), { headers: { authorization: `Bearer ${deps.accessToken}` } });
if (!res.ok) throw new Error(`Calendar answered ${res.status}. Your events are unchanged.`);
const body = (await res.json()) as { items?: GoogleEvent[] };
return (body.items ?? []).map((e) => fromGoogle(e, cal));
}

interface GoogleEvent {
id: string;
summary?: string;
status?: string;
location?: string;
description?: string;
start?: { date?: string; dateTime?: string; timeZone?: string };
end?: { date?: string; dateTime?: string };
attendees?: { email?: string }[];
}

export function fromGoogle(e: GoogleEvent, calendarId: string): Omit<CalendarEvent, keyof Base> {
const allDay = Boolean(e.start?.date);
return {
externalId: e.id,
title: e.summary ?? "(no title)",
start: allDay ? e.start!.date! : new Date(e.start?.dateTime ?? 0).toISOString(),
end: allDay ? (e.end?.date ?? e.start!.date!) : new Date(e.end?.dateTime ?? e.start?.dateTime ?? 0).toISOString(),
allDay,
...(e.location ? { location: e.location } : {}),
...(e.description ? { notes: e.description } : {}),
...(e.attendees?.length ? { attendees: e.attendees.map((a) => a.email ?? "").filter(Boolean) } : {}),
...(e.status === "cancelled" ? { cancelled: true } : {}),
calendarId,
};
}

/**
* The local calendar day an event starts on, as YYYY-MM-DD. All-day events already are one. Timed
* events are converted through the local calendar rather than by arithmetic on the instant, which
* is the only way that is right on the days that are 23 and 25 hours long.
*/
export function localDayOf(event: Pick<CalendarEvent, "start" | "allDay">): string {
if (event.allDay) return event.start.slice(0, 10);
return dayKey(new Date(event.start));
}

export function dayKey(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}

/**
* The seven local days starting at `from`. Stepping by calendar day rather than by 24 hours is what
* keeps a week correct across a clock change: adding 24h to 01:00 on the day the clocks go back
* lands on the same day again, and the week silently loses a Sunday.
*/
export function weekDays(from: string): string[] {
const [y, m, d] = from.split("-").map(Number);
return Array.from({ length: 7 }, (_, i) => dayKey(new Date(y, m - 1, d + i)));
}

/** Events grouped by the local day they start on, in the order given. */
export function byDay(events: readonly Pick<CalendarEvent, "start" | "allDay">[]): Map<string, number[]> {
const map = new Map<string, number[]>();
events.forEach((e, i) => {
const key = localDayOf(e);
map.set(key, [...(map.get(key) ?? []), i]);
});
return map;
}
164 changes: 164 additions & 0 deletions packages/lifeboard/src/google/gmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Reading and sending mail.
//
// Sending is the part that needs care. Every outbound message names its recipient and waits for a
// confirmation — there is no path in this file that sends without one, because "the assistant
// emailed my child's teacher" is not a bug you can apologise your way out of. The confirmation is
// modelled as a value you have to produce and hand back, so forgetting it is a type error rather
// than a missing check.

export interface MailMessage {
id: string;
threadId?: string;
from: string;
to: string[];
subject: string;
date: string;
snippet: string;
body: string;
labels?: string[];
}

export interface GmailDeps {
accessToken: string;
fetch?: typeof fetch;
}

const API = "https://gmail.googleapis.com/gmail/v1/users/me";

export async function listMessages(query: string, deps: GmailDeps, max = 25): Promise<string[]> {
const f = deps.fetch ?? fetch;
const u = new URL(`${API}/messages`);
u.searchParams.set("q", query);
u.searchParams.set("maxResults", String(max));
const res = await f(u.toString(), { headers: { authorization: `Bearer ${deps.accessToken}` } });
if (!res.ok) throw new Error(`Gmail answered ${res.status}.`);
const body = (await res.json()) as { messages?: { id: string }[] };
return (body.messages ?? []).map((m) => m.id);
}

export async function getMessage(id: string, deps: GmailDeps): Promise<MailMessage> {
const f = deps.fetch ?? fetch;
const res = await f(`${API}/messages/${encodeURIComponent(id)}?format=full`, {
headers: { authorization: `Bearer ${deps.accessToken}` },
});
if (!res.ok) throw new Error(`Gmail answered ${res.status} for message ${id}.`);
return parseMessage((await res.json()) as GmailPayload);
}

interface GmailPart {
mimeType?: string;
body?: { data?: string };
parts?: GmailPart[];
}
interface GmailPayload {
id: string;
threadId?: string;
snippet?: string;
labelIds?: string[];
payload?: GmailPart & { headers?: { name: string; value: string }[] };
}

export function parseMessage(m: GmailPayload): MailMessage {
const headers = new Map((m.payload?.headers ?? []).map((h) => [h.name.toLowerCase(), h.value]));
return {
id: m.id,
...(m.threadId ? { threadId: m.threadId } : {}),
from: headers.get("from") ?? "",
to: (headers.get("to") ?? "").split(",").map((s) => s.trim()).filter(Boolean),
subject: headers.get("subject") ?? "(no subject)",
date: headers.get("date") ?? "",
snippet: m.snippet ?? "",
body: textOf(m.payload) || (m.snippet ?? ""),
...(m.labelIds ? { labels: m.labelIds } : {}),
};
}

/** Plain text if there is any, else the HTML with its tags stripped. */
function textOf(part?: GmailPart): string {
if (!part) return "";
if (part.mimeType === "text/plain" && part.body?.data) return decode(part.body.data);
const nested = (part.parts ?? []).map(textOf).filter(Boolean);
if (nested.length) return nested[0];
if (part.mimeType === "text/html" && part.body?.data) {
return decode(part.body.data).replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
return "";
}

function decode(b64url: string): string {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
try {
const bin = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "="));
return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
} catch {
return "";
}
}

// --- sending -----------------------------------------------------------------------------------

export interface Draft {
to: string;
subject: string;
body: string;
}

/**
* A confirmation for one specific draft. Produced only by the UI, after the person has seen the
* recipient and the text. It carries the draft it approves, so an approval cannot be reused for a
* different message.
*/
export interface SendApproval {
approved: Draft;
at: string;
by: string;
}

export const approve = (draft: Draft, by: string, now = new Date()): SendApproval =>
({ approved: draft, at: now.toISOString(), by });

export function isApprovalFor(approval: SendApproval, draft: Draft): boolean {
return approval.approved.to === draft.to && approval.approved.subject === draft.subject && approval.approved.body === draft.body;
}

/** Sends only what was approved, and refuses anything else. */
export async function sendMail(draft: Draft, approval: SendApproval, deps: GmailDeps): Promise<string> {
if (!isApprovalFor(approval, draft))
throw new Error("That message is not the one that was approved. Nothing was sent.");
const f = deps.fetch ?? fetch;
const res = await f(`${API}/messages/send`, {
method: "POST",
headers: { authorization: `Bearer ${deps.accessToken}`, "content-type": "application/json" },
body: JSON.stringify({ raw: rfc822(draft) }),
});
if (!res.ok) throw new Error(`Gmail would not send it (${res.status}). Nothing was sent.`);
const body = (await res.json()) as { id?: string };
return body.id ?? "";
}

export function rfc822(draft: Draft): string {
const lines = [
`To: ${draft.to}`,
`Subject: ${encodeHeader(draft.subject)}`,
"MIME-Version: 1.0",
'Content-Type: text/plain; charset="UTF-8"',
"",
draft.body,
].join("\r\n");
return base64url(lines);
}

/** RFC 2047 for anything outside ASCII, so an accent in a subject line does not arrive as mojibake. */
function encodeHeader(text: string): string {
// eslint-disable-next-line no-control-regex
if (/^[\x00-\x7F]*$/.test(text)) return text;
return `=?UTF-8?B?${base64(text)}?=`;
}

const base64 = (s: string): string => {
const bytes = new TextEncoder().encode(s);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
};
const base64url = (s: string): string => base64(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
Loading
Loading