phase-7: motion, an installable app, and a capture that survives no signal

GSAP carries the counter roll-ups, the bandeja card physics, the dialog
transitions and the three success moments FLOWS.md allows. Every one of
them checks prefers-reduced-motion first and does nothing when it is set.

boneyard and canvas-ui are not what SPEC.md's stack table says they are:
on npm the names belong to two abandoned projects that do neither job.
The skeletons were already ours; the two canvas spots are now sixty lines
each with no dependency. DECISIONS.md records the substitution.

The app installs, keeps a scan taken with no network in IndexedDB and
sends it when there is one, falls back to a page that explains itself,
and can push a deadline notice. Reading the log of what is queued is the
source of truth, so the notice clears when the capture actually lands.

The CSP now allows scripts by per-request nonce rather than by
'unsafe-inline'. That forced /offline to render per request: a
prerendered page carries a build-time nonce no live policy matches, so
its scripts were blocked and it never hydrated.

Two crashes fixed on the way. web-push throws on a VAPID subject that is
not https: or mailto:, and the code handed it APP_PUBLIC_URL, so any
machine with push keys died at boot; a misconfigured optional channel now
switches itself off and says why. And a subscription the push service
answers 410 for is deleted rather than retried forever.

Lighthouse on the production build: accessibility 100, best practices 96,
SEO 100, performance 73. The performance number is not trustworthy on
this machine and DECISIONS.md says why; total blocking time did fall from
17.6s to 1.7s once the hero canvas stopped drawing at full resolution
every frame and the landing page stopped importing GSAP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-09-05 20:48:48 +00:00
co-authored by Claude Opus 5
parent 4c39926483
commit e4eb1617d1
59 changed files with 2148 additions and 67 deletions
+4
View File
@@ -48,6 +48,10 @@ OCR_MODEL=claude-sonnet-4-6
# Optional. Web push is hidden in the UI when unset. Generate: npx web-push generate-vapid-keys
PUSH_VAPID_PUBLIC_KEY=
PUSH_VAPID_PRIVATE_KEY=
# Who the push service can contact about this deployment. An https: or mailto: URL, which
# is what RFC 8292 allows. Defaults to APP_PUBLIC_URL when that is https, then to
# mailto:SMTP_FROM. With none of the three, push stays off and says so at boot.
PUSH_VAPID_SUBJECT=
# Optional. Without SMTP_HOST, verification codes and emails are logged to stdout.
SMTP_HOST=
+2
View File
@@ -10,6 +10,7 @@ import { documentRoutes } from './routes/documents';
import { fileRoutes } from './routes/files';
import { lookupRoutes } from './routes/lookup';
import { meRoutes } from './routes/me';
import { pushRoutes } from './routes/push';
export interface AppHandle {
app: Hono<AppEnv>;
@@ -59,6 +60,7 @@ export function createApp(deps: AppDeps): AppHandle {
api.route('/deadlines', deadlineRoutes(deps));
api.route('/declarations', declarationRoutes(deps));
api.route('/admin', adminRoutes(deps));
api.route('/push', pushRoutes(deps));
app.route('/api', api);
+196
View File
@@ -0,0 +1,196 @@
import { PushConfigDto } from '@impuestos/contracts';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createChannels, sendNotification, type Channels } from '../../modules/notifications';
import { parseEnv } from '../../lib/env';
import { createHarness, TEST_ENV, type Harness } from '../../test/harness';
const VAPID = {
PUSH_VAPID_PUBLIC_KEY:
'BLJMd-C-nT92I_geF-K4yHFAudpBu4HEzUbV6Y4CBrobIv4F5oNhyc_GR7e8jb5rXA9KT6PtBEf5h6K_q-Wqogo',
PUSH_VAPID_PRIVATE_KEY: 'Q4_ibICYoI43BnVVIX2AJeI7-E8uHhFIpQNFtThwJAw',
};
const SUBSCRIPTION = {
endpoint: 'https://push.example.test/subscription/abc',
keys: { p256dh: 'BOrOaGVLBTEjaHVsY2FrZXM', auth: 'c2VjcmV0LWF1dGg' },
};
let h: Harness;
let cookie: string;
beforeAll(async () => {
h = await createHarness({ env: VAPID });
cookie = await h.signIn('maria@demo.local', 'demo-maria-1');
});
afterAll(async () => {
await h.close();
});
const json = (path: string, init: RequestInit = {}) =>
h.app.request(path, {
...init,
headers: { cookie, 'content-type': 'application/json', ...(init.headers ?? {}) },
});
describe('GET /push/config', () => {
it('hands out the public key without a session, because it is public', async () => {
const response = await h.app.request('/api/push/config');
expect(response.status).toBe(200);
const config = PushConfigDto.parse(await response.json());
expect(config.publicKey).toBe(VAPID.PUSH_VAPID_PUBLIC_KEY);
});
it('is null on a deployment with no keys, so the UI can hide the offer', async () => {
const plain = await createHarness();
try {
const config = PushConfigDto.parse(await (await plain.app.request('/api/push/config')).json());
expect(config.publicKey).toBeNull();
} finally {
await plain.close();
}
});
});
describe('POST /push/subscribe', () => {
it('needs a session', async () => {
const response = await h.app.request('/api/push/subscribe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(SUBSCRIPTION),
});
expect(response.status).toBe(401);
});
it('rejects a body that is not a subscription', async () => {
const response = await json('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify({ endpoint: 'not-a-url' }),
});
expect(response.status).toBe(400);
});
it('stores one row per endpoint, however many times the browser subscribes', async () => {
expect((await json('/api/push/subscribe', { method: 'POST', body: JSON.stringify(SUBSCRIPTION) })).status).toBe(200);
expect(
(
await json('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify({ ...SUBSCRIPTION, keys: { ...SUBSCRIPTION.keys, auth: 'rotated' } }),
})
).status,
).toBe(200);
const rows = await h.deps.handle.db
.selectFrom('push_subscriptions')
.selectAll()
.where('endpoint', '=', SUBSCRIPTION.endpoint)
.execute();
expect(rows).toHaveLength(1);
// The second subscribe refreshed the keys rather than leaving a stale row behind.
expect(JSON.parse(rows[0]?.keys ?? '{}')).toEqual({ ...SUBSCRIPTION.keys, auth: 'rotated' });
});
it('forgets the subscription when the browser gives it up', async () => {
const response = await json('/api/push/subscribe', {
method: 'DELETE',
body: JSON.stringify({ endpoint: SUBSCRIPTION.endpoint }),
});
expect(response.status).toBe(200);
const rows = await h.deps.handle.db
.selectFrom('push_subscriptions')
.selectAll()
.where('endpoint', '=', SUBSCRIPTION.endpoint)
.execute();
expect(rows).toHaveLength(0);
});
});
describe('a subscription the push service says is gone', () => {
it('is deleted, and does not count as a delivery', async () => {
const db = h.deps.handle.db;
const user = await db
.selectFrom('user')
.select('id')
.where('email', '=', 'maria@demo.local')
.executeTakeFirstOrThrow();
await db
.updateTable('notification_prefs')
.set({ push_enabled: 1 })
.where('user_id', '=', user.id)
.execute();
await db
.insertInto('push_subscriptions')
.values({
id: 'wiped-phone',
user_id: user.id,
endpoint: 'https://push.example.test/subscription/wiped',
keys: JSON.stringify(SUBSCRIPTION.keys),
created_at: new Date().toISOString(),
})
.execute();
// A phone that was reset: the push service answers 410 for good.
const channels: Channels = {
push: async (targets) => ({ gone: targets.map((target) => target.endpoint) }),
email: null,
telegram: null,
};
const result = await sendNotification(
{ db, channels },
{
userId: user.id,
notification: {
kind: 'declaration_ready',
form: '120',
period: '2026-08',
declarationId: 'whatever',
},
},
);
expect(result.delivered).not.toContain('push');
const rows = await db.selectFrom('push_subscriptions').selectAll().where('user_id', '=', user.id).execute();
expect(rows).toHaveLength(0);
});
});
describe('the VAPID subject', () => {
function channelsFor(overrides: Record<string, string>) {
const parsed = parseEnv({ ...TEST_ENV, ...VAPID, ...overrides });
if (!parsed.ok || !parsed.env) throw new Error(parsed.message);
return createChannels(parsed.env);
}
/**
* The regression: web-push throws on a subject that is not https: or mailto:, and
* APP_PUBLIC_URL is http in development. Taking it unchecked killed the API at boot.
*/
it('switches push off rather than crashing when there is no usable subject', () => {
expect(channelsFor({ APP_PUBLIC_URL: 'http://localhost:3005' }).push).toBeNull();
});
it('takes an https APP_PUBLIC_URL', () => {
expect(channelsFor({ APP_PUBLIC_URL: 'https://impuestos.example' }).push).not.toBeNull();
});
it('falls back to the sending address', () => {
expect(
channelsFor({ APP_PUBLIC_URL: 'http://localhost:3005', SMTP_FROM: 'avisos@impuestos.example' })
.push,
).not.toBeNull();
});
it('prefers an explicit subject over both', () => {
expect(
channelsFor({
APP_PUBLIC_URL: 'http://localhost:3005',
PUSH_VAPID_SUBJECT: 'mailto:soporte@impuestos.example',
}).push,
).not.toBeNull();
});
});
+74
View File
@@ -0,0 +1,74 @@
import { PushSubscriptionInput } from '@impuestos/contracts';
import { Hono } from 'hono';
import { uuidv7 } from 'uuidv7';
import type { AppDeps, AppEnv } from '../context';
import { HttpError } from '../errors';
import { requireUser } from '../middleware';
/**
* Browser push subscriptions. The key a browser needs to subscribe is public by
* definition, so reading it needs no session; storing a subscription does.
*/
export function pushRoutes(deps: AppDeps): Hono<AppEnv> {
const routes = new Hono<AppEnv>();
const db = deps.handle.db;
routes.get('/config', (c) =>
c.json({ publicKey: deps.env.PUSH_VAPID_PUBLIC_KEY ?? null }),
);
routes.post('/subscribe', async (c) => {
const user = requireUser(c);
const parsed = PushSubscriptionInput.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) throw new HttpError('validation_error', { detail: parsed.error.issues });
// One row per endpoint: re-subscribing the same browser refreshes its keys rather than
// leaving a second row that will be pushed to twice.
const existing = await db
.selectFrom('push_subscriptions')
.select('id')
.where('endpoint', '=', parsed.data.endpoint)
.executeTakeFirst();
if (existing) {
await db
.updateTable('push_subscriptions')
.set({ user_id: user.id, keys: JSON.stringify(parsed.data.keys) })
.where('id', '=', existing.id)
.execute();
} else {
await db
.insertInto('push_subscriptions')
.values({
id: uuidv7(),
user_id: user.id,
endpoint: parsed.data.endpoint,
keys: JSON.stringify(parsed.data.keys),
created_at: new Date().toISOString(),
})
.execute();
}
return c.json({ ok: true } as const);
});
routes.delete('/subscribe', async (c) => {
const user = requireUser(c);
const body: unknown = await c.req.json().catch(() => null);
const endpoint =
typeof body === 'object' && body !== null && 'endpoint' in body
? (body as { endpoint: unknown }).endpoint
: null;
if (typeof endpoint !== 'string') throw new HttpError('validation_error', { field: 'endpoint' });
await db
.deleteFrom('push_subscriptions')
.where('user_id', '=', user.id)
.where('endpoint', '=', endpoint)
.execute();
return c.json({ ok: true } as const);
});
return routes;
}
+2
View File
@@ -44,6 +44,8 @@ const EnvObject = z.object({
PUSH_VAPID_PUBLIC_KEY: optionalString,
PUSH_VAPID_PRIVATE_KEY: optionalString,
/** Contact for the push service. Must be an https: or mailto: URL, per RFC 8292. */
PUSH_VAPID_SUBJECT: optionalString,
SMTP_HOST: optionalString,
SMTP_PORT: z.coerce.number().int().positive().max(65535).default(587),
+1
View File
@@ -19,6 +19,7 @@ function recordingChannels(): Channels & { sent: { channel: string; message: Out
sent,
push: async (_targets, message) => {
sent.push({ channel: 'push', message });
return { gone: [] };
},
email: async (_to, _subject, message) => {
sent.push({ channel: 'email', message });
+38 -3
View File
@@ -19,7 +19,8 @@ export interface PushTarget {
* the fan-out skips it (FLOWS.md section 9).
*/
export interface Channels {
push: ((targets: PushTarget[], message: OutgoingMessage) => Promise<void>) | null;
/** Resolves with the endpoints the push service says are gone, so they can be pruned. */
push: ((targets: PushTarget[], message: OutgoingMessage) => Promise<{ gone: string[] }>) | null;
email: ((to: string, subject: string, message: OutgoingMessage) => Promise<void>) | null;
telegram: ((chatId: string, message: OutgoingMessage) => Promise<void>) | null;
}
@@ -32,10 +33,31 @@ export function createChannels(env: Env): Channels {
};
}
/**
* RFC 8292 requires the VAPID subject to be an `https:` or a `mailto:` URL, and web-push
* throws rather than warns when it is not. `APP_PUBLIC_URL` is http in development, so
* taking it unchecked crashes the API at boot on any machine that has push keys set.
*/
function vapidSubject(env: Env): string | null {
if (env.PUSH_VAPID_SUBJECT) return env.PUSH_VAPID_SUBJECT;
if (env.APP_PUBLIC_URL.startsWith('https:')) return env.APP_PUBLIC_URL;
if (env.SMTP_FROM) return `mailto:${env.SMTP_FROM.replace(/^.*</, '').replace(/>.*$/, '')}`;
return null;
}
function createPush(env: Env): Channels['push'] {
if (!env.PUSH_VAPID_PUBLIC_KEY || !env.PUSH_VAPID_PRIVATE_KEY) return null;
webpush.setVapidDetails(env.APP_PUBLIC_URL, env.PUSH_VAPID_PUBLIC_KEY, env.PUSH_VAPID_PRIVATE_KEY);
const subject = vapidSubject(env);
if (!subject) {
// A misconfigured optional channel switches itself off. It does not take the API down.
console.warn(
'[boot] push is configured but PUSH_VAPID_SUBJECT is not, and APP_PUBLIC_URL is not https. Push is off.',
);
return null;
}
webpush.setVapidDetails(subject, env.PUSH_VAPID_PUBLIC_KEY, env.PUSH_VAPID_PRIVATE_KEY);
return async (targets, message) => {
const payload = JSON.stringify({
@@ -45,11 +67,24 @@ function createPush(env: Env): Channels['push'] {
});
// One dead subscription must not stop the others: a phone that was wiped is normal.
await Promise.allSettled(
const results = await Promise.allSettled(
targets.map((target) =>
webpush.sendNotification({ endpoint: target.endpoint, keys: target.keys }, payload),
),
);
// 404 and 410 mean the browser threw the subscription away. Anything else is a
// transient failure and the row stays: a network blip is not a reason to stop
// notifying someone forever.
const gone: string[] = [];
results.forEach((result, index) => {
const endpoint = targets[index]?.endpoint;
if (!endpoint || result.status !== 'rejected') return;
const status = (result.reason as { statusCode?: number } | undefined)?.statusCode;
if (status === 404 || status === 410) gone.push(endpoint);
});
return { gone };
};
}
+7 -2
View File
@@ -57,8 +57,13 @@ export async function sendNotification(
});
if (targets.length > 0) {
await deps.channels.push(targets, message);
delivered.push('push');
const { gone } = await deps.channels.push(targets, message);
if (gone.length > 0) {
await deps.db.deleteFrom('push_subscriptions').where('endpoint', 'in', gone).execute();
}
// Everything the push service did not reject is a delivery. A wiped phone is not a
// failure of this send, it is a subscription that no longer exists.
if (gone.length < targets.length) delivered.push('push');
}
}