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:
co-authored by
Claude Opus 5
parent
4c39926483
commit
e4eb1617d1
@@ -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=
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user