Miru tells you when a new episode of an anime you are watching comes out. It sounds like a small feature, but it combines three things that rarely get along: an external API, a scheduled job and sending notifications through systems like APNs and FCM. Here is how I built it with Supabase and what I had to fix along the way.
The architecture
There is no server of mine to keep running:
pg_cron, inside Supabase’s Postgres database, launches a job every two hours (0 */2 * * *).- The job calls, via
pg_net, an Edge Function (Deno) namednotify-episodes. - The function reads who has notifications on, asks AniList when the next episode of each ongoing series airs and sends pushes with a shared module for APNs (iOS) and FCM (Android).
What the function does
In order:
- Tokens and preferences. It reads the push tokens, excludes people who turned episode notifications off and remembers each person’s language, to write the text in Italian or English.
- Ongoing series. It takes the entries in “watching” status for the remaining users.
- AniList in batches. It asks for the next episode with a GraphQL query, in groups of 50 identifiers; if one batch fails the others carry on.
- 24-hour window. It notifies only if the episode airs within the next 24 hours.
- No duplicates. Each entry has a
notified_epcolumn with the last episode already notified: it only sends if the episode is newer. - Update after success.
notified_epis written only when the send returnsok; if the token turns out to be invalid I delete it.
The core of the deduplication is three lines:
if (!airing) continue // no episode scheduled
if (airing.airingAt > horizon) continue // too far away (over 24 hours)
if (airing.episode <= (entry.notified_ep ?? 0)) continue // already notified
The text changes with the time left: “just came out”, “out in a few minutes”, “out in N hours”, in the user’s language.
First problem: anyone could call the function
The cron called the function with Supabase’s anon key, which by design is public. So anyone who knew it could invoke notify-episodes whenever they wanted.
I added a shared secret: the function answers 401 to requests that do not carry the secret in a dedicated header, and the cron was rescheduled to send it.
The value lives in the function’s secrets, not in the code: the migration that reschedules the cron, in the repository, holds only a placeholder, and I insert the real value when I run it, so it never ends up in git.
Second problem: trusting what the client sends
notify-friend sends the “someone wants to add you” notification. It received a receiver_id from the client and sent the push to that person without checking anything else: a client could make notifications reach any user whose identifier it knew.
The fix: before sending, the function checks that a pending friend request really exists between sender and receiver, otherwise it answers 403.
The same check applies to recommendations. It is the classic IDOR problem: the server must not trust an identifier just because it comes from the client.
A detail that costs a lot: timeouts
Calls to APNs and FCM have a 10-second timeout with AbortController. Without it, a hung call blocks the whole function while there are still notifications to send.
What I learned
- A public key identifies the app, it does not authorize anyone: if a function does something sensitive, it needs a real secret.
- Deduplication must be written after a successful send, not before.
- Every identifier that comes from the client must be checked against the state the server knows.
