HowToDoIt
Structured how-to app · an evolution of Playbook
A checklist names the task and stops. It never says what "finished" is supposed to look like, which is why the friction at home was never a chore going undone. It was that organized looked like one thing to me and another to my wife, and neither of us could agree. An ordered set of photos settled that without anyone having to have the conversation.
I got the format from a Panera manager building the morning bakery case off a laminated standard operating procedure: capture "done right" once, so nobody has to guess. Household was the original idea that tried it on chores. Households usually track who needs to do the chore but never how, and teaching that becomes extra "invisible work", but if a visual guide were available, then the work was always available to point to. This grew up into Playbook: chores group into profiles, and profiles get assigned to a person a week at a time, and the images moved off Firebase onto Azure storage.
HowToDoIt is the same idea coming back to an SOP: a compliance tool for a shop instead of a kitchen, a reference for someone opening a coffeeshop, running a clothing store, or running the back house at an ecommerce site.
// Gates state-changing requests. Safe (GET/HEAD/OPTIONS) methods and any action
// marked [AllowAnonymous] stay public, so browsing keeps working without a login.
public void OnAuthorization(AuthorizationFilterContext context)
{
if (context.ActionDescriptor.EndpointMetadata.Any(m => m is IAllowAnonymous)) return;
if (SafeMethods.Contains(context.HttpContext.Request.Method)) return;
var user = context.HttpContext.User;
if (user?.Identity is not { IsAuthenticated: true })
{
context.Result = new UnauthorizedResult(); // 401 — not signed in
return;
}
// Entra External ID (CIAM) doesn't emit a usable email for social logins — the
// token's preferred_username is a synthetic {oid}@tenant UPN — so fall back to
// the stable object-id. Fail closed: no matching identity → deny.
var email = FirstClaim(user, EmailClaimTypes);
var objectId = FirstClaim(user, ObjectIdClaimTypes);
var allowed = (email != null && _allowedWriters.Contains(email))
|| (objectId != null && _allowedWriterObjectIds.Contains(objectId));
if (!allowed)
context.Result = new ObjectResult("You are not authorized to make changes.")
{ StatusCode = StatusCodes.Status403Forbidden }; // 403 — signed in, not a writer
}
Why this one: the whole security model in one filter , reads stay public, writes are gated server-side. The interesting part is the fallback: social logins arrive with no email claim, so it keys the writer list on the stable object-id and fails closed rather than trusting the UI.
A small suite, pointed at the two things this card claims. The write gate is tested through every branch it has, including the one that bit me: a token carrying no email at all now fails closed with a 403 instead of matching an empty entry. The other half is step ordering, pinned as pure functions that return null at the boundaries rather than silently doing nothing.
ordering Upload a batch of photos and they come back in arbitrary order, and captions get written afterward, against images that are already up. Explicit sort order wasn't a feature I set out to build; it was the correction that made steps exist at all. Without it the app is a pile of pictures with no progression through them.
media Step photos go to an Azure Blob container (public-read), uploaded through the API, and then the browser fetches them straight from Blob. The API never buffers an image: nothing serializes bytes into memory on the way out and deserializes them on the way back in. Routing binaries through a controller looks fine with ten photos, and is exactly how a read path gets slow and bloated at scale.
authz Reads are anonymous; every write is gated server-side by an allow-list filter: 401 if unauthenticated, 403 if the caller isn't a listed writer. The UI hides the buttons, but the server is the authority.