← Home

यंग फाउंडेशन

The Yung Foundation website, end to end

Every page a visitor can reach, everything staff can edit, the AI that drafts for them, and the architecture underneath — on one page.

Tier one

The site

What is on it, what it does, who can change it, and what the AI does for them. No technical background assumed.

Site map साइटचा नकाशा

Sixteen public pages in four groups, plus the files machines read and the door staff use.

Yung Foundation · public site Core Content Take action Legal /  — home (the logo) /about /satpuda /programs /programs/ :slug /blog /blog/ :slug /news  ?item=N opens a story /gallery /events /team /resources /donate (a button) /contact /volunteer the last two accept a form /legal/privacy /legal/terms /legal/accessibility one page per published row in the database in the header; the footer's three columns carry the rest NOT IN THE MENUS /404 /robots.txt /sitemap.xml /api/health /admin  — staff only, behind a login
Figure 1 — every URL a visitor can reach. The two gold, dashed routes are not files: one page is generated for each published programme and each blog post, so adding a programme in the admin adds a page to the site. The header carries six links, with the logo as Home and Donate as a button beside them; Blog, News & media, Team and Resources live in the footer's three columns with the rest, so nothing left the site when the header was trimmed. /sitemap.xml names every page twice, once per language, and no admin path.

What each page does प्रत्येक पान

One line each, roughly in the order a visitor meets them.

A page never shows a blank where a fact is missing. Every block on /donate, /about and /satpuda is tied to its own data and disappears when that data is absent. An empty "80G —" line would read as a claim the foundation cannot back, and donors rely on those numbers, so the whole line is suppressed instead. Links follow the same rule: a social icon appears only when its address is set, a Maps link only when an event has one, and a testimonial's "Read the story" only while that post is published.

Found and shared शेअर आणि शोध

Most visitors arrive from a forwarded WhatsApp message or a search. Both are designed for.

Two languages दोन भाषा

Marathi is the site's own language. English is the translation — not the other way round.

STORED IN THE DATABASE RENDERED ON AN ENGLISH PAGE { mr: "आमची गोष्ट",   en: "Our story" } Our story tagged as English, set in the Latin font { mr: "आमची गोष्ट" } no English yet आमची गोष्ट (मराठी) tagged as Marathi — screen readers and the font follow the real text { } field not filled in at all nothing — the heading and its whitespace are gone too an empty state, not an empty box { mr: "आमची गोष्ट",   en: "" } a blank line, with the heading still above it an empty string is a present value, so the fallback never fires This is why the admin never saves an emptied field — it removes the key instead.
Figure 2 — one field, four outcomes. The first three are the design working. The fourth is the failure it is built to avoid, and it is the reason the admin deletes a translation rather than blanking it: a field containing an empty string looks filled in to every check, so the Marathi never gets a chance to show.

What staff can edit संपादन

Twenty-three tabs behind a login at /admin, every one drawn from a single registry of fields. Between them they cover every word and every photograph on the public site — there is nothing an editor has to ask a developer to change. The admin itself switches between Marathi and English, because the people editing it are in Dhule and Nandurbar.

What editing feels like

The AI helpers AI ची मदत

Eight helpers in the admin. Every one of them writes a draft into the form; none of them saves anything. The editor reads the draft, changes it, and presses Save — or presses Undo.

The rules every helper keeps

Built for phones on slow networks फोनसाठी

The audience is in the Satpuda hills — Dhadgaon and Shirpur, in Nandurbar and Dhule districts. Most of them arrive on a phone, on a patchy connection.

Tier two

How it is built

The stack, the data model, the AI layer, and the decisions that shape everything else. Written for whoever maintains this next.

The stack तंत्रज्ञान

Read out of package.json and astro.config.mjs.

Server-rendered rather than statically built, on purpose. Every word of page furniture — the menu labels, the site title, the page text — comes out of the database, so a static build would have to be regenerated every time an editor changed a comma. The cost is that every request reads the database, a known and recorded trade.

The development server and the production runtime are different engines. npm run dev runs on Node; production runs on Cloudflare's workerd, which has no DOM, no Image and no canvas. Code that works on one can fail on the other, so any server-side change is verified with npm run build && npm run preview, which runs the real runtime locally. It is also why photographs are resized in the browser: it is the only machine in the path that can.

What happens on a request विनंतीचा प्रवास

One page view, from the tap to the HTML. Everything below happens inside a single Cloudflare Worker before any markup is sent.

CLOUDFLARE WORKER SUPABASE Browser a phone, usually GET Middleware 1 · pick the language 2 · check the admin gate 3 · preview? writers only before any HTML exists public Astro page renders on the server no valid session 303 → /admin/login anon key Postgres the content Storage the files Row-level security unpublished rows never leave the database finished HTML — no script bundle The gate runs before any markup is produced, and it fails closed: if the check itself errors, the answer is the login page, not a server error. It also revalidates the session against the auth server rather than trusting the cookie it was sent.
Figure 3 — one request, start to finish. The page never filters for "published" itself. It asks for programmes and the database returns only the published ones, because the policy says so. A redundant filter in the query would hide a broken policy — the app would look correct while the database leaked. Step 3 matters only to editors: ?preview=1 from a signed-in writer marks the page up for the admin's live preview and is never cached; from anyone else it is ignored.

The data model माहितीची रचना

Twenty-six tables, grouped by what they are for, from eighteen migrations.

Rules the schema enforces rather than documents. Every content table carries its own sort_order and an is_published flag that defaults to off, so nothing is ever accidentally live. No table has a path column — there is no cover_path anywhere, because a path is something you can type wrongly and a foreign key is not. And every web address an editor can type is checked by the database: WhatsApp, Facebook, YouTube and Google Maps links must be https on those companies' own hosts, and a testimonial's extra links must be https with no spaces. A javascript: link is refused for every writer, not just by this form.

Relationships the database keeps. An album and its photographs share a category through a two-column foreign key, so a photograph cannot sit in an album from another category, and moving an album moves its photographs. A category still in use cannot be deleted. A testimonial links to a blog post by ID, so renaming the post's address cannot break the link, and deleting the post only detaches it.

Derived facts are not stored. An event has a start date and an end date and no status column; whether it is upcoming, ongoing or past is worked out on every render from the Indian date. A stored status is a fact that goes wrong quietly at midnight.

Who can read what प्रवेश नियंत्रण

Permission is enforced by the database on every statement, not by the code that asks.

The all-powerful database key is used nowhere. It is declared to the build as server-only and secret, so importing it into anything the browser sees is a build error rather than a review comment. On top of that, continuous integration searches the built output on every push for six kinds of leaked key — both formats of the database key, and the Anthropic, Google (old and new format) and AgentRouter key shapes — and fails the build if it finds one.

Media is referenced by ID ओळख क्रमांकाने

The part of this codebase least like the obvious version, and the one the most features now stand on.

HOW AN IMAGE URL IS PRODUCED programs.cover_media_id gallery_items.media_id testimonials.media_ids[] <figure data-media-id> 17 media columns on 12 tables, plus figures in bodies media .storage_path alt text and caption live here, once, in both languages publicUrl(   'media', path) a real URL the only way in THE TWO BUCKETS what publicUrl() will accept media public — photographs, covers, the donation QR files private — no public read policy of any kind outside the helper's accepted types, so linking one from a public page does not compile THE COLUMN THIS SCHEMA DOES NOT HAVE programs.cover_path A path can be typed wrongly, can point at a file nobody uploaded, and gives no way to ask which pages use a photograph before deleting it.
Figure 4 — every picture on the site is a foreign key. Covers, icons, gallery tiles, testimonial photographs and even a photograph placed inside a blog post are stored as the ID of a media row, never as a path. That one decision is what the "Used in" line, the alt text written once and shown everywhere, and the captions that travel with a photograph all stand on. A figure whose photograph becomes a draft or is deleted simply disappears from the page rather than rendering broken.

One reference deletes, the rest detach. Removing a photograph from the library empties every cover, icon and slot that used it — except a gallery tile, which is nothing without its photograph and is deleted with it. The library's delete warning says which, per page, before anyone confirms.

Where an editor's save goes बदल कुठे जातात

There is no content API. The one admin endpoint drafts text; it never saves a page.

A VISITOR READING A PAGE Browser not signed in Worker renders the page select AN EDITOR SAVING A CHANGE Browser signed in at /admin insert / update, under their own session no API layer in between — and therefore none to bypass Row-level security the same policy on both lanes Postgres + Storage Dashed means a signed-in session. The visitor's lane is anonymous throughout — the anonymous key is public by design and grants nothing on its own.
Figure 5 — there is no endpoint between an editor and the database. The admin does not post to an API that then talks to Postgres; the editor's browser writes directly, carrying their own session, and the same policy that hides a draft from a visitor is what decides whether the write is allowed. One check instead of two, and no second place for the two to disagree. The AI endpoint in Figure 6 sits beside this lane, not in it: its drafts come back to the form, and they reach the database only through this same save.

The AI layer AI ची रचना

Eight helpers, six tasks, one endpoint: /api/admin/ai. Everything the helpers do passes through it, and it is the only place a provider key is ever used.

Admin form a signed-in editor presses "Draft English" CLOUDFLARE WORKER · /api/admin/ai POST Refused, in this order 501 · no provider key set 415 · body is not JSON 401 · nobody signed in 403 · not a writer 400 · fails validation 503 · no model chosen then, and only then, a call 1 · the active model chosen in the AI tab if retryable 2 · the backup key same model · Gemini only if retryable 3 · the fallback model + backup key if Gemini PROVIDERS Gemini Google AI Studio · x-goog-api-key Anthropic Messages API · x-api-key AgentRouter OpenAI-compatible · Bearer token one call per attempt · 60 s timeout Every answer is checked on its way back · numbers not in the source are listed beside the draft · Devanagari digits → Latin; other Indic scripts refused · captions equal to the alt text dropped; slugs checked · HTML sanitised in the browser before it enters a field SUPABASE ai_usage one row per attempt: who, task, tab, provider, model, tokens in and out, cost, ok, fallback, backup key and a short error code — never a reply a draft in the field, unsaved, with one Undo Retryable means quota (402), rate limit (429), a server error, a timeout or an empty answer. A refused key or a misspelt model stops at once, because a second provider answering would hide the misconfiguration. Provider replies never reach the browser, only the checked draft.
Figure 6 — one AI call, start to finish. The endpoint runs under the editor's own session, with no all-powerful key, so it can read only what that editor could read. It returns a draft and writes nothing but its own log row. Saving the draft is the ordinary write in Figure 5.

The six tasks

The story behind this section, slide by slide. How each helper was asked for, built and caught out is told in Drafts, Not Decisions, the 23-slide deck on the AI in this admin.

The live preview थेट पूर्वावलोकन

"See it on the site" shows the real public page, not a copy of it — with what the editor is typing already in place.

THE ADMIN · SIGNED IN Editor typing, not saved Marathi or English Appears on: its own page · the list · home cards as you type same origin Frame the real page ?preview=1 the edited row, outlined, in view own language switch GET HTML Middleware asks is_writer() yes: row markers + a listener script private · no-store · noindex drafts shown · not counted A visitor who adds ?preview=1 gets the ordinary page: no markers, no script, no drafts Fields are found by matching their saved text inside the marked row, so only eleven row containers carry a marker and the roughly ninety places that print text carry nothing. Visitors are sent no extra bytes at all.
Figure 7 — the preview is the site itself. Rendering the real page means the preview can never drift from what visitors see. Switching the frame's language renders that language without writing the cookie, so the admin's own language is left alone. A new, unsaved row says "save as draft first". Impact stats, Key facts and Values also keep a small instant preview drawn in the admin, because those change with every keystroke.

The JavaScript budget JavaScript चा हिशोब

Spent where it earns its place, and measured from the build rather than estimated.

The public site

The admin, behind the login

The admin pays what the public site does not. It sits behind a login, is used by a handful of staff, and caches after the first visit, so its size is a deliberate trade. Every change to it is measured off the build, with and without the change, and the difference is written down.

How it stays honest तपासण्या

The checks that run without anyone remembering to run them, and the habits behind the rest.