Skip to content
How this site was built

Project 09 · The system behind this site

How I built this site, and the business system behind it.

It started as a page about me. Today it is five services on one database that take a client from a first request to a signed contract and a paid invoice, and it is where I test everything before I trust it.

This is the complete tour: plain words first, diagrams you can explore, and the real code behind each part.

Services
5
Database, 30 tables
1
Automated tests
1,212
Scheduled runs a day
288
Panel screens
14
Database migrations
31
Languages, every page
2
AI crawlers welcomed
27

By Majed Alandajani · Updated 2 November 2026

On this page

The idea

It started as a page about me. It became the system I test everything on.

In July 2026 this site was a single page: who I am, what I do, where I have worked. Then I needed a place to take requests from clients, so I added a services store and appointment booking. Then I needed to send proposals, so I built proposals. Then contracts, invoices, online payment, a mailbox and a blog. With every real need the site grew, until it became a small system that runs a complete business.

That changed its purpose. It is now my test bed. Whatever I learn, I apply here first, on a live system that takes real requests and is connected to a real payment gateway. I measure how stable, useful and secure it is, and only then do I decide whether it deserves to move into a company's systems. A mistake here costs me. A mistake in a company system costs employees, customers and money.

LearnApply here firstMeasureDecideMove to companiesReject, with the reason
Select any box to see what it does and what it connects to. Swipe sideways to see the whole map.

Measure

Speed in real visitors' browsers, errors, test results, and whether it actually saved time. A feeling is not a measurement.

This page is the complete tour: what the system does, how its parts talk to each other, and the code behind the parts I am proudest of. Every section starts in plain words. If you want the engineering, open "Under the hood", or open every technical detail at once from the menu.

The code boxes are real excerpts from the system. They are shortened, and names are changed wherever they would reveal something private. How the private panel is protected, and every key, address and client detail, is left out on purpose.

Architecture

Five services, one database, and everything they talk to.

The system runs on Cloudflare's edge network as five small services: the website, the store, the blog, the API and my operations panel. Each one has a single job and is deployed on its own, and all of them read and write one database, so there is only ever one version of the truth.

private linksVisitorsClientsMeWebsiteNext.js, staticStoreAstro, static plus liveBlogHono, from the databaseOperations panelHono, privateSearch and AI enginessitemap, llms.txtAPIHono, schedule, emailOne databaseD1, 30 tablesTappaymentsEmailin and outGooglecalendar, search dataLinkedInarticle sharingGitHub Actionstests, deploys, backups
Select any box to see what it does and what it connects to. Swipe sideways to see the whole map.

One database

All five services share it. Nothing keeps its own copy, so a client's state is a fact in one place, not something pieced together from files and messages.

Under the hood

Cloudflare D1 (SQLite): 30 tables, 55 indexes and 7 triggers, built by 31 ordered migrations. Six triggers make evidence tables append-only, and one freezes signed contracts.

API routes
22
Panel routes
124
Shared modules
20
Database tables
30
Ordered migrations
31
Protective triggers
7
Under the hood

Twenty shared TypeScript modules hold every rule the API and the panel must agree on: the document seal, short references, Riyadh time, slot collisions, the mail queue, the payment client. Each rule is written once, so the two services cannot drift apart.

A second, schema-only database exists for one purpose: every migration runs there before it touches production.

Use cases

Five kinds of user, and what each one can do.

A system is easiest to understand through the people who use it. Two of the five are not people at all: the clock that runs scheduled work, and the search and AI engines that read the site.

VisitorClientSearch and AIMeThe clockRead the site and blogBook or ask for a quotePay onlineApprove and signFollow it all in one linkUnderstand who I amRun sales, order to invoiceAnswer email in the panelPublish and share articlesWatch the numbersRemind, retry, reconcileBack up every night
Select any box to see what it does and what it connects to. Swipe sideways to see the whole map.

Me

I run sales, delivery, email, content and the numbers from one panel, on a computer or on my phone.

The commercial path

From a first request to a signed contract and a paid invoice.

This is the heart of the system. Each stage creates the next one, so the state of any client is a fact recorded in one place. Walk through it step by step.

A request comes in

A visitor picks a service in the store and sends a request, or books a time. The order is created, the client gets an acknowledgement, and my phone shows a notification.

Under the hood

One atomic batch writes the customer, the order, its short reference and the queued emails. The form sends an attempt key, so a retry after a lost answer returns the same order instead of creating a second one.

// One key per booking attempt, created when the form opens and sent with
// every retry. A retry after a lost answer gets the same order back, never a
// second one. The key is retired only when the attempt is really over.
const res = await fetch(`${apiBase}/api/orders`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ ...body, attemptKey }),
});
const j: any = await res.json().catch(() => ({}));

// Nothing was charged, so the next press is a new attempt, not a retry.
if (j.error === 'charge_create_failed' || j.error === 'attempt_closed') attemptKey = '';
if (!res.ok) throw new Error(j.error || 'error');

// A paid product answers with the payment page, also on a retry.
if (j.redirectUrl) { location.assign(j.redirectUrl); return; }

msgEl.textContent = en()
  ? 'Request received. I will contact you within one business day.'
  : 'تم استلام طلبك. سأتواصل معك خلال يوم عمل واحد.';
form.reset();
attemptKey = ''; // the next submission is a genuinely new booking

01 / 08

Money

Payments built for the bad day.

With customer money, a payment that works is not enough. Every path that touches money was designed for the bad day: the lost answer, the double click, the duplicate message, the client who pays twice.

  • The charge is created at the press. A Tap payment page expires after 30 minutes, so nothing is created when a link is emailed. The page is created when the client presses Pay, and it is always fresh.

  • Verify first, then write. Every payment message from Tap is verified before anything is recorded, and the charge is then read again from Tap. Only Tap's own answer changes the state of any money.

  • One event, once. The same payment message arriving twice is recorded once, and one reconciler is shared by the message, the return page and the scheduled job, so whichever arrives first, the result is the same.

  • Refunds are written down before they are requested. If the answer is lost, the record still says a refund may have happened, and its retry key belongs to that record, so a retry can never refund twice.

  • One clear alert for anything unusual. A double payment, a card payment after a bank transfer, test money on a real order, an amount that changed: each produces exactly one alert to me saying what to check.

  • A selling mode per product. Each product is sold by quote, by booking, or by direct payment, and I can switch it from the panel without rebuilding the store.

  • Switched on by hand, after a real check. Online payments are switched on only by me, from the panel, and the switch first runs a real check against Tap before it agrees.

The first complete payment went through with Apple Pay in Tap's test environment: the payment was captured, its signature checked out, and both emails arrived.

// Verify first, write second. The duplicate check is built from fields
// inside the message, so recording an unverified message would let a forged
// one block the real one.
if (!(await verifyWebhookSignature(tap, payload, received))) {
  return json(c, { error: "bad_signature" }, 401);
}

const dedupeKey = await sha256Hex(`${payload?.id}|${payload?.status}|${payload?.transaction?.created}`);

// The same event twice is one event.
const ins = await env.DB.prepare(
  "INSERT OR IGNORE INTO webhook_events (tap_charge_id, tap_status, dedupe_key, signature_valid, processing_status) VALUES (?, ?, ?, 1, 'verified')",
).bind(String(payload?.id || ""), String(payload?.status || ""), dedupeKey).run();
if (ins.meta.changes === 0) return json(c, { ok: true, duplicate: true });

// A signature proves who sent the message, not that its body is true. The
// charge is read again from Tap, and only that answer moves any money state.
const charge = await retrieveCharge(tap, String(payload.id));
await reconcile(env, charge);
Under the hood

Races are tested, not hoped for. A purpose-built laboratory replays two customers acting in the same instant, and a server dying halfway through a request, deterministically. It runs the real handlers one database statement at a time against a fake Tap that follows Tap's published statuses, and it checks 14 rules that must always hold, such as no order paid twice and no money without a record.

Appointments

An owned booking engine: two people, one slot, no double booking.

Instead of a booking tool on someone else's platform, the system has its own engine. I set weekly hours, close specific dates, and hold time for myself. Visitors see only slots that are really free, up to 14 days ahead.

When two people press book on overlapping times in the same instant, both requests are written, then each one looks again, and the older claim keeps the time. The newer one cancels itself and asks the visitor to pick another slot. A paid booking holds its slot while the client pays, and the hold is released on its own if the payment never arrives.

Confirming a request creates its own private video meeting link, emails it to the client, and mirrors the event to my Google Calendar. A reminder goes out the day before. I can also subscribe my phone's calendar to a private feed that works without Google at all.

// Two people press "book" on overlapping times in the same instant. Both
// rows are written; then each request re-reads the window, and the older
// claim keeps the time. The newer one cancels itself and tells the visitor.
export function slotVerdict(
  mineId: number,
  startsAt: string,
  hours: number,
  windowRows: SlotRow[],
): { overlapping: SlotRow[]; mustYield: boolean } {
  const startMs = new Date(startsAt).getTime();
  const endMs = endOf(startsAt, hours);
  const overlapping = windowRows.filter((r) =>
    r.id !== mineId && collides(startMs, endMs, new Date(r.starts_at).getTime(), endOf(r.starts_at, r.hours)),
  );
  // If we cannot identify our own row, we do not yield: destroying a real
  // booking on an uncertain fact is worse than an overlap I can see and fix.
  const mustYield = mineId > 0 && overlapping.some((r) => mineId > r.id);
  return { overlapping, mustYield };
}
Under the hood

A unique index on the start time rejects an identical start immediately, and the overlap rule lives in a shared module used by both the API and the panel. A request I have not confirmed within 36 hours is released with an apology email, so a slot is never held by a request nobody answered.

The email to the client goes before the calendar copy. Google is a courtesy and has no say in whether the client is told.

Automation

Every five minutes, thirteen jobs run, and each one is safe to repeat.

A scheduled job runs every five minutes, 288 times a day. It settles payments, releases expired holds, retries failed emails, bills monthly retainers, checks the whole system, expires unanswered requests, sends reminders, prepares my morning digest, re-verifies contract signatures, cleans old logs, and publishes the next article from the blog queue.

Running 288 times a day means every job must be safe to repeat. A reminder is claimed in the database before it is sent, so it is sent once whatever happens. And the first thing the job does on every run is record that it ran. If it ever stops, a warning appears at the top of every screen in my panel, because a job cannot report its own death.

// Every five minutes, 288 times a day. Each step is isolated: one failing
// step is logged and the rest still run. The order is deliberate: money is
// settled before holds are released, and housekeeping always comes last.
async scheduled(_event: ScheduledController, env: Env, _ctx: ExecutionContext) {
  const step = <T>(name: string, work: () => Promise<T>) => work().catch((e) => {
    console.error(`cron: ${name}:`, String(e?.message || e));
    return null;
  });
  const lastTick = await step("heartbeat", () => heartbeat(env));
  await step("reconcile", () => reconcilePending(env, lastTick));
  await step("booking holds", () => expireUnpaidBookings(env));
  await step("mail retry", () => retryWhenDue(env));
  await step("retainer billing", () => generateRetainerInvoices(env));
  await step("estate check", () => monitorEstate(env));
  await step("request expiry", () => expireStaleBookings(env));
  // Claimed once per row, so 288 runs a day still send one reminder.
  await step("appointment reminders", () => sendAppointmentReminders(env));
  await step("digest", () => sendReminderDigest(env));
  await step("contract reminders", () => remindExpiringContracts(env));
  // Before housekeeping: a signature that stopped proving anything cannot wait.
  await step("signature sweep", () => sweepSignatures(env));
  await step("retention", () => purgeOperationalLogs(env));
  await step("blog queue", () => publishFromQueue(env));
}
Under the hood

A failed email is retried once an hour: ordinary mail up to three attempts, money mail for a full day. The system check probes the public site and the panel, flags a stuck mail queue or payment messages that used up their retries, and emails me once per new set of problems instead of every five minutes.

An outside monitor on GitHub also checks the store, the blog and the API every 30 minutes, because a Worker cannot reliably watch its neighbours from inside the same zone.

Back office

One panel instead of ten tools.

Most small businesses jump between an inbox, a CRM, a signing tool, an invoicing tool, a calendar and an analytics dashboard. I wanted all of it in one place, organised in the order the work happens: selling, delivery, content, and the system itself. On a computer the screens sit in a side rail; on a phone they open from the bottom, where the thumb reaches.

Schematic of the operations panel1 / 15

Orders

Every request with its brief, status and agreed amount, searchable, with one-tap contact by WhatsApp, phone or email.

  • Each status change emails the client a short update, once per status
  • Record a bank transfer in one click; money that arrived twice is flagged
  • Turn an order into a proposal draft, or create its invoice
  • Full and partial refunds, written down before any money moves
Under the hood

The panel is server-rendered HTML straight from the database, with no front-end framework and no asset pipeline: 9,310 lines of TypeScript across 27 files, 124 routes, and two runtime dependencies. It is written in Arabic first, and one button switches it to English using a dictionary of 561 entries. Every refusal comes back to the screen I was on as a translated message, never a bare error page, and nearly every action writes a line to the append-only audit log.

// The panel is written in Arabic and rendered on the server. One button
// walks the live page and swaps each text for its English entry (561 of
// them), flips the direction, and re-formats every date in Riyadh time.
function apply(lang){
  var en = lang === "en", html = document.documentElement;
  html.lang = lang; html.dir = en ? "ltr" : "rtl";
  document.querySelectorAll("[data-en]").forEach(function(el){
    if (el.dataset.ar === undefined) el.dataset.ar = (el.textContent || "");
    el.textContent = en ? (el.dataset.en || el.dataset.ar) : el.dataset.ar;
  });
  texts.forEach(function(p){
    if (!en) { p[0].nodeValue = p[1]; return; }
    var t = swap(p[1]); if (t !== null) p[0].nodeValue = t;
  });
  var fmt = tsFmt(en);
  if (fmt) document.querySelectorAll("time[datetime]").forEach(function(el){
    var iso = el.getAttribute("datetime"); if (!iso) return;
    try { el.textContent = fmt.format(new Date(iso)); } catch (e) {}
  });
  document.title = en ? (DICT[docTitle] || docTitle) : docTitle;
  btn.textContent = en ? "عربي" : "EN";
}

Email

Email that cannot be lost, and a mailbox that lives in the panel.

Every email the system sends starts life as a row in the database, written in the same atomic step as the fact it announces. If an order is marked paid, its receipt is already queued; if that step fails, neither exists. The email is sent after that, and a failed send is retried every hour.

Email sent to the domain arrives in my panel, parsed, grouped into conversations, and stored before anything else happens. A reply in Arabic and an original in English land in the same thread. My phone gets a notification, and I answer from the same screen.

// An email is first a row, written in the same batch as the fact it
// announces. If the order is marked paid, the receipt is already queued;
// if the batch fails, neither exists. The send comes after the commit.
export function mailClaim(env: { DB: D1Database }, msg: Mail, when?: { sql: string; bind: unknown[] }): D1PreparedStatement {
  return env.DB.prepare(
    `INSERT OR IGNORE INTO notifications_log (order_id, template_key, recipient, status, provider, idempotency_key, subject, body_text, attempts, claimed_at)
     SELECT ?, ?, ?, 'queued', 'resend', ?, ?, ?, 1, ? ${when ? `WHERE ${when.sql}` : ""}`,
  ).bind(msg.orderId, msg.templateKey, msg.to, msg.idem, msg.subject, msg.text, new Date().toISOString(), ...(when?.bind ?? []));
}

// The send behind a claim this caller won. A failed send is left to the
// hourly retry; it is never lost and never sent twice.
export async function sendClaimed(env: { DB: D1Database } & EmailEnv, msg: Mail): Promise<boolean> {
  const r = await sendEmail(env, { to: msg.to, subject: msg.subject, text: msg.text });
  if (r.ok) {
    await env.DB.prepare("UPDATE notifications_log SET status = 'sent', provider_message_id = ?, claimed_at = NULL WHERE idempotency_key = ?")
      .bind(r.id || null, msg.idem).run();
  } else {
    await env.DB.prepare("UPDATE notifications_log SET claimed_at = NULL WHERE idempotency_key = ?")
      .bind(msg.idem).run().catch(() => { /* the lease runs out by itself */ });
  }
  return r.ok;
}
Under the hood

Outbound mail goes through Resend and carries its own Message-ID, which is stored so that client replies thread correctly. Inbound mail arrives through Cloudflare Email Routing into the API's email handler: the raw message is parsed, HTML-only mail is converted to text with its links kept, and every field is capped before it is stored.

Content

A blog that publishes itself, and shares to LinkedIn in one press.

The blog reads articles straight from the database, so publishing is instant and needs no build. I can publish by hand, or put finished articles in a queue: on a rhythm I set, at a fixed hour in Riyadh time, the system publishes the next one and tells search engines about it through IndexNow.

When an article mentions another one that is not published yet, the mention stays plain text, and turns into a link by itself on the day that article goes live. Under each article I can add short follow-ups, shown as a thread.

LinkedIn is connected once through its official sign-in. After that, one press on a published article posts it to my profile with its title, summary and link, and the panel warns me two weeks before the connection expires.

Write or importArabic and EnglishQueuea rhythm I setPublish by handPublishedserved from the databaseIndexNowengines told at onceLinkedInone pressRSS and sitemap
Select any box to see what it does and what it connects to. Swipe sideways to see the whole map.

Published

The blog serves it from the database within a couple of minutes, in Arabic and English, with structured data for search engines.

Under the hood

Pages are cached at the edge for two minutes, feeds and the sitemap for five. Only successful pages are cached, so a missing page never outlives the publish that fixes it.

// Escape everything first, then allow a short list of Markdown. No raw tag
// can survive, a link must be http(s) or a path on this site, and a link to
// an article that is not published yet stays plain text until it is.
function esc(s: string): string {
  return s.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch] as string));
}

function inline(s: string, isPublished: (slug: string) => boolean): string {
  return s
    .replace(/`([^`]+)`/g, "<code>$1</code>")
    .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
    .replace(/\*([^*]+)\*/g, "<em>$1</em>")
    .replace(
      /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/(?!\/)[^\s)]*)\)/g,
      (_m, text: string, href: string) => {
        const self = SELF_ABS.exec(href);
        const path = self ? self[1] : href;
        if (!path.startsWith("/")) return `<a href="${href}" rel="noopener">${text}</a>`;
        const m = ARTICLE.exec(path);
        if (m && !m[1].includes(".") && !isPublished(m[1])) return text;
        return `<a href="${href}">${text}</a>`;
      },
    );
}
Under the hood

The Markdown renderer escapes everything first, then allows eight constructs: headings, code blocks, lists, quotes, bold, italic, inline code and links. A link must be http(s) or a path on this site, so no script can ever be smuggled in through an article.

Arabic and English

Two languages, built in from the first line.

English is the main language, and Arabic is not a translation layer on top of it. Every page exists twice, as a real page with its own address. Every sentence lives in one file as an English and Arabic pair, so both versions are built from the same parts and cannot drift apart.

Switching language keeps you on the same page and changes the address, without a reload. Before anything is drawn, a tiny script sets the language and direction. After the build, another script writes Arabic into the bytes of every Arabic page, so search engines and screen readers see Arabic without running any code.

The lesson I keep: test Arabic first. Letter spacing that looks elegant in English pulls Arabic letters apart, so تقنية starts to read like تقذية. That is now a rule in the styles, and a check in the automated visual audit.

// Every page has a real route in both languages: the Arabic one is the
// English path under /ar. Switching maps the CURRENT path, so the reader
// stays on the same page instead of landing on the home page.
function pathFor(lang: Lang, current: string): string {
  const bare = current === '/ar' || current === '/ar/'
    ? '/'
    : current.startsWith('/ar/') ? current.slice(3) : current;
  const normalized = bare.startsWith('/') ? bare : `/${bare}`;
  if (lang !== 'ar') return normalized;
  return normalized === '/' ? '/ar/' : `/ar${normalized}`;
}

// No reload and no extra history entry: the address simply follows the language.
function syncPath(lang: Lang) {
  const current = window.location.pathname;
  const target = pathFor(lang, current);
  if (target !== current) {
    window.history.replaceState(null, '', target + window.location.search + window.location.hash);
  }
}
Under the hood

Layout uses logical properties (start and end instead of left and right), so one stylesheet serves both directions. The language switch runs inside a React transition, so the pressed button paints first and the full re-render follows. Numbers are formatted for each language, and the Arabic font ships three weights with measured fallback faces, preloaded on Arabic pages after the build.

SEO and GEO

Built to be found by search engines, and understood by AI.

SEO is being found by search engines. GEO is being understood by AI assistants, and mentioned in their answers. Both start with the same thing: a page that says clearly who wrote it, what it is about, and how it relates to everything else.

creatorauthorPersonone fixed identityProfilePagethe home pageWebSitethis sitealumniOfeducationknowsAbout18 topicsCreativeWorkeach project pageTechArticlethis pageworkExamplesites I builtsubjectOfLinkedIn posts
Select any box to see what it does and what it connects to. Swipe sideways to see the whole map.

One person, one identifier

Every page describes the same person with the same fixed identifier, so search engines and AI assistants never have to guess whether two pages are about the same Majed.

Under the hood

A JSON-LD @graph in the root layout declares the Person with an @id on the home page. Every other node, on every page, points back to that @id instead of repeating the person.

  • Every word is in the HTML before any script runs.

  • Every page declares its own official address and its version in the other language, with hreflang.

  • One official address for the whole site: duplicate public copies were switched off or redirected to the main domain.

  • robots.txt welcomes 27 AI crawlers by name, and llms.txt gives AI engines a plain summary of who I am and where everything lives.

  • A sitemap lists every page in both languages, and new articles are announced through IndexNow.

  • Search Console and GA4 numbers come into the panel daily, so I watch what is found and not only what is published.

To be fair about it: this makes the site readable and understandable. Being mentioned in answers also takes real content and references from other places, over time.

// Both language routes of a case page call this one builder, so the English
// and Arabic pages cannot disagree. `creator` points at the same Person id as
// the home page, which makes every project a link in one knowledge graph.
const graph = {
  '@context': 'https://schema.org',
  '@graph': [
    {
      '@type': 'CreativeWork',
      '@id': `${url}#project`,
      name: project.name[lang],
      description: project.description[lang],
      inLanguage: lang,
      url,
      sameAs: project.liveUrl,
      creator: { '@id': `${SITE}/#person` },
      keywords: project.stack.join(', '),
    },
    {
      '@type': 'BreadcrumbList',
      itemListElement: [
        { '@type': 'ListItem', position: 1, name: OWNER[lang], item: home },
        { '@type': 'ListItem', position: 2, name: project.name[lang], item: url },
      ],
    },
  ],
};

Performance

A 3D logo, and still fast on a phone.

The home page opens with a 3D scene: 47 characters of code drift, gather, and are absorbed by metal strokes that write my monogram as you scroll. A scene like that can easily make a site slow, so the rule was simple: the text loads first, and the 3D never stands in its way.

The mobile speed score was 44. The obvious suspect was the 3D scene, but measuring said otherwise: a scroll library was nudging a paragraph after it appeared, and the browser counted the main content as appearing again, later than it really did. I found it by blocking one file at a time and measuring again. The pinned scroll became plain CSS, the 3D moved to a background thread, and analytics now waits for the visitor. The score rose to about 85, and screenshots at four scroll positions matched the previous version.

  • The 3D scene waits for the intro and for the browser to be idle, then draws inside a Web Worker. If that fails it draws on the main thread, and without WebGL a flat drawing of the mark appears instead.

  • It draws only when something changes, slows itself for ambient motion, and stops completely when it is off screen.

  • Each home section hydrates on its own, and sections below the fold skip layout until the browser is idle.

  • Analytics loads on the first interaction or after seven seconds, and sends only the page path.

  • Files with hashed names are cached for a year, and anyone who prefers reduced motion gets the final state with quick fades.

Real visitors are what matter most. When I measured in September, 75% of real visits showed their main content within one second.

// The 3D scene draws inside a Web Worker, so three.js never blocks the page.
// Three outcomes race (ready, error, silence) and a single latch settles the
// race once: the worker keeps the canvas, or the page falls back to drawing
// on the main thread. The visitor sees the logo either way.
const worker = new Worker(new URL('./scene.worker.tsx', import.meta.url));
let settled = false;
const fallBack = () => {
  if (settled) return;
  settled = true;
  setUseWorker(false);
};
const timer = window.setTimeout(fallBack, READY_TIMEOUT_MS);
worker.onerror = fallBack;
worker.onmessage = (e: MessageEvent<{ type: string }>) => {
  if (e.data.type === 'error') fallBack();
  if (e.data.type === 'ready' && !settled) {
    settled = true;
    window.clearTimeout(timer);
  }
};

const rect = canvas.getBoundingClientRect();
const offscreen = canvas.transferControlToOffscreen();
const dpr = Math.min(Math.max(1, window.devicePixelRatio || 1), 1.5);
worker.postMessage(
  { type: 'init', canvas: offscreen, width: rect.width, height: rect.height, dpr },
  [offscreen],
);

Storefront

A static store that still knows what is true right now.

The store is built as static pages from the catalog in the database. When I change the catalog in the panel and press Publish, a GitHub Actions job rebuilds and deploys it. If the API cannot be reached during the build, the catalog kept in code is used, so a publish never fails because of a network hiccup.

On top of the static pages sits a small live layer: on every visit the store asks the API how each product is sold right now. That is how I can switch a product from a quote request to direct payment without any rebuild.

On a computer, the booking form grows out of the button you pressed; on a phone it becomes a sheet from the bottom. Each attempt carries its own key, so a retry after a lost answer never creates a second order, and the human check loads only when the form opens.

// At build time the catalog comes from the database through the API. If the
// API cannot be reached, the catalog kept in code is used, so a network
// hiccup can never fail a publish.
let OFFS: Off[] = OFFERINGS.map(toCard);
try {
  const r = await fetch(`${SITE}/api/products`, { headers: { accept: 'application/json' } });
  if (r.ok) {
    const j: any = await r.json();
    if (Array.isArray(j?.offerings) && j.offerings.length > 0) OFFS = j.offerings;
  }
} catch { /* offline build: the fallback stays */ }
Under the hood

The store is Astro with two runtime dependencies, self-hosted fonts, one page per service with Service structured data, and plain-language privacy and terms pages. The language you choose travels with you across the home page, the store, the blog and the booking page. The category carousel measures its geometry rarely and moves its cards with CSS custom properties only.

Delivery and quality

From a commit to production, with nothing deployed by hand.

Every change takes the same road. A push runs the tests, works out which of the five services actually changed, refuses to deploy if the database has a migration waiting, deploys only what changed, checks each live address, and then opens the public site in a real browser, in both languages, on a phone and a desktop.

PushPlanwhat changed?Migration gatenothing pending?Deploychanged services onlyVerify livewith backoffBrowser audit2 languages, 2 sizesTests1,212 automatedNightly backupcredentials removedUptime watchevery 30 minutes
Select any box to see what it does and what it connects to. Swipe sideways to see the whole map.

Migration gate

If production has a database migration waiting, everything stops. Code never reaches a database that is not ready for it.

Automated tests
1,212
Invariants in the race lab
14
Panel screens audited
23
Deploy jobs
7
Nightly backups kept
14
Days of restore history
30

Database changes are rehearsed on a separate staging database first, with a backup of production taken before anything runs. Restores are rehearsed too: a backup is restored into a throwaway database and its row counts compared before production is ever touched.

# A push deploys only what it changed. Shared code is compiled into both the
# API and the panel, so a change there redeploys both. Test-only changes do
# not change what a Worker serves, so they deploy nothing.
- name: Plan
  run: |
    has() { grep -qE "$1" <<<"$CHANGED"; }
    WORKER_FILES="$(grep -vE '^store-system/[^/]+/test/' <<<"$CHANGED" || true)"
    hasw() { grep -qE "$1" <<<"$WORKER_FILES"; }

    SHARED=0
    if hasw '^store-system/shared/'; then SHARED=1; fi

    if hasw '^store-system/api/'   || [ "$SHARED" = 1 ]; then on api;   fi
    if hasw '^store-system/admin/' || [ "$SHARED" = 1 ]; then on admin; fi
    if hasw '^store-system/blog/'; then on blog; fi

    SITE_FILES="$(grep -vE '^scripts/ci/' <<<"$CHANGED" || true)"
    if grep -qE '^(src/|public/|assets/|scripts/|next\.config|wrangler\.worker\.jsonc|package\.json)' <<<"$SITE_FILES"; then
      on portfolio
    fi
Under the hood

Tests use node:test with no framework. They run the shipped Worker through real requests against a database built from the real migrations. A concurrency laboratory replays races and crashes deterministically, a runtime proof runs inside workerd itself, and a separate visual audit opens 23 panel screens at two widths in both languages, failing on a clipped label or on letter spacing applied to Arabic.

Security

Security and privacy, as far as can be said in public.

Some things are left out of this page on purpose: how the private panel is protected, where private pages live, and every key and every limit. What can be shared is the shape of it.

  • One address, encrypted, in careful steps. The site answers on HTTPS only, with HSTS raised in steps: five minutes, then a week, then a full year once everything was proven. A browser that has stored HSTS will not open the site unencrypted, and the server cannot take that back, so it was not rushed.

  • No duplicate copies. Two complete public copies of the site were found on other addresses, one of them unencrypted. One was switched off and the other now redirects to the main domain.

  • Headers and a policy that was watched before it was enforced. Baseline security headers on all five services, and a Content Security Policy that ran in report-only mode for about a week before being enforced, so it blocks what it should without breaking the site.

  • Evidence the application cannot rewrite. Payment messages are verified before they are recorded, signed contracts are frozen by the database, and approvals, signatures and the audit log are append-only.

  • A private panel behind an identity gate. The operations panel is not reachable without passing an identity gate, and the public forms are protected by a human check that loads only when needed.

  • No secret in the code. No secret lives in the code repository. Nightly backups have credentials removed and are scanned before they are saved.

  • Privacy as an action. Erasing a person under the Saudi data protection law (PDPL) is one action. They are removed everywhere at once, while the documents the law requires me to keep survive with their name replaced.

/*
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()

Judgement

What I decided, what I rejected, and what this cannot prove.

  • Rejected: a major upgrade. The next major versions of the website framework and the store generator both worked. But the site's core JavaScript grew by 17%, and the store lost its glass effect in Chrome without workarounds. I kept the current versions and set a date to review them.

  • Replaced: a scroll library. The library that pinned the hero distorted how the page's load was measured. Plain CSS does the same job, with the same look.

  • Kept on purpose: the 3D library version. Newer releases change how the logo is drawn, and the logo does not change without a reason.

  • Changed after review: how fixes are treated. After building the payment modes every test passed, yet a code review found 33 real issues, and a second review found 14 more, two of them introduced by fixes from the first. Since then, any fix that touches money or customer data is treated as new code that needs its own review.

What this test bed cannot prove

  • Scale. A personal site receives a limited number of visitors. What holds here may not hold with thousands of users, and that needs separate load testing.

  • Teams. I am the only user of the panel. Permissions, approvals between departments, and handing over accounts when someone leaves are not tested here.

  • Compliance. The site trains me in the controls, but it does not prove that a company complies with data protection law or with cybersecurity controls.

  • Existing systems. I built this from scratch. Companies have older systems, vendors and ways of working, and moving anything into them needs its own plan.

So nothing here is ready for companies just because it works here. Working here earns it the next trial, inside the company, on a small scale first.

Technologies

Every technology in the system, and what it does.

  • Next.js 15 and React 19: the website, exported as static pages in two languages.

  • TypeScript: every service, from end to end.

  • Tailwind CSS: the design tokens and layout of the website.

  • three.js, React Three Fiber and drei: the 3D logo and the ARA Brain story scene, drawn in a Web Worker where possible.

  • Lenis: smooth scrolling on the home page, skipped for anyone who prefers reduced motion.

  • Astro: the services store, built as static pages from the database.

  • Hono: the API, the blog and the operations panel.

  • Cloudflare Workers: hosting for all five services, plus the scheduled job and inbound email.

  • Cloudflare D1 (SQLite): one database for everything, with triggers that protect evidence.

  • Cloudflare Email Routing and Resend: email in and email out.

  • postal-mime: reading inbound email.

  • Tap Payments: online payments through a hosted page, including Apple Pay.

  • Web Crypto (SHA-256 and HMAC): document seals, signature tokens and payment message verification.

  • Google Calendar, Search Console and GA4: calendar mirroring, and search and visitor numbers inside the panel.

  • LinkedIn API: one-press article sharing.

  • IndexNow: instant notice to search engines.

  • Cloudflare Turnstile: the human check on public forms.

  • Web Push and a service worker: notifications and the app badge on my phone.

  • GitHub Actions: tests, deploys, store rebuilds, nightly backups and an outside uptime monitor.

  • node:test and node:sqlite: 1,212 automated tests against a real database built from the migrations.

  • Playwright: visual audits of the panel and of the live site in a real browser.

Why I wrote all of this down

I built this system to run my own work, and to have a place where I can try an idea for real before I recommend it to anyone. Writing it down is part of the same discipline: if I cannot explain a part simply, I probably do not understand it well enough yet.

If you are building something similar, or you want a system like this for your company, I am happy to talk.