Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9a9b1ae
feat(recipe-demo): scaffold Angular 21 SSR app with Pico CSS
armando-navarro Aug 20, 2026
188d9cc
feat(recipe-demo): add Firebase web config
armando-navarro Aug 20, 2026
e483fc1
feat(recipe-demo): firebase sdk, firestore rules, indexes, seed script
armando-navarro Aug 20, 2026
927c697
style(recipe-demo): run prettier over the scaffolded sources
armando-navarro Aug 20, 2026
7fb14c9
feat(recipe-demo): firebase DI tokens with client and server app configs
armando-navarro Aug 20, 2026
59b09ec
feat(recipe-demo): render the signed-in user on the server
armando-navarro Aug 25, 2026
3cbc967
feat(recipe-demo): email password auth with sign-in page
armando-navarro Aug 21, 2026
77b6c7e
chore(recipe-demo): raise initial bundle budgets to fit the firebase sdk
armando-navarro Aug 21, 2026
8b00505
fix(recipe-demo): stop the built server falling back to client rendering
armando-navarro Aug 21, 2026
397f76d
chore(recipe-demo): default generated components to OnPush
armando-navarro Aug 21, 2026
e54c78a
feat(recipe-demo): add server-rendered recipe list with filter and sort
armando-navarro Aug 21, 2026
dd183c4
feat(recipe-demo): add per-user likes with denormalized counts
armando-navarro Aug 21, 2026
396cb08
feat(recipe-demo): add owner-only recipe deletion
armando-navarro Aug 22, 2026
2559e97
feat(recipe-demo): add AI recipe generation
armando-navarro Aug 23, 2026
ac8eeda
docs(recipe-demo): app README w/design decisions, known gaps, setup
armando-navarro Aug 23, 2026
5bf0dd2
chore(recipe-demo): configure the App Hosting deployment
armando-navarro Aug 23, 2026
465c382
fix(recipe-demo): let the server answer the auth guard
armando-navarro Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions recipe-demo/.firebaserc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"projects": {
"default": "recipe-demo-97859"
}
}
44 changes: 44 additions & 0 deletions recipe-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.

# Compiled output
/dist
/tmp
/out-tsc
/bazel-out

# Node
/node_modules
npm-debug.log
yarn-error.log

# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace

# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*

# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/

# System files
.DS_Store
Thumbs.db
13 changes: 13 additions & 0 deletions recipe-demo/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"printWidth": 100,
"singleQuote": true,
"arrowParens": "avoid",
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
130 changes: 130 additions & 0 deletions recipe-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Recipe demo, raw Firebase JS SDK

A minimal recipe app built on the Firebase JS SDK directly. It uses four Firebase features: Authentication, Firestore, AI Logic, and App Check.

This is not a starter template and not production-ready. Known gaps are listed under [Production gaps](#production-gaps) rather than solved.

## What the app does

- **`/`** renders the public recipe list. Filter by cuisine, sort by Newest or A-Z. Signed-in visitors can like recipes.
- **`/signin`** signs in or creates an account with email and password.
- **`/create-recipe`** generates a recipe with Firebase AI Logic and writes it to the public list (must be signed in).

## Deployed instance

<https://raw-sdk--recipe-demo-97859.us-central1.hosted.app>

## Design decisions

This is not an exhaustive list of design decisions. Some here are gaps left open on purpose due to the nature of this being a minimal demo, and some are choices that are not obvious from the code.

### The recipe list is carried from server to browser by hand

Angular's automatic state transfer only covers `HttpClient`, which Firestore never touches, so `recipe-store.ts` writes the server's query result into `TransferState` and reads it back on the client. Without it the server-rendered list is blanked by the first client render and reappears when the listener responds.

Worth knowing what this does not fix. The browser still opens its own Firestore listener and reads the same documents again, so the visible flash is gone but the second read is not.

### The store idles until a page asks for the list

`RecipeStore` is provided in root so the listener survives navigation, which means `/create-recipe` injects it too, purely to generate. A `listWanted` signal keeps the query parameters `undefined` until a page that actually displays recipes calls `watchRecipes()`. Without it, `/create-recipe`'s server render would block on a Firestore read whose result it never shows.

### Each recipe stores its own like count

The number of likes is kept on the recipe document as `likeCount`, duplicating something you could work out by counting the documents under `users/{uid}/likes/{recipeId}`. Storing the total is what makes the list cheap: rendering twenty cards is one query, where counting likes per card would be twenty more.

The cost of keeping that copy is that nothing derives it for you, so keeping it in step is deliberate work. `toggleLike` writes the user's like document and the recipe's counter in a single `writeBatch`, and the security rules refuse either write without the other, so the two can only move together.

### There are no Cloud Functions

Everything runs in the browser or during server rendering. That single choice is why two of the [Production gaps](#production-gaps) below exist: nothing cleans up like documents when a recipe is deleted, and nothing rate-limits generation per user.

### There are no unit tests

This demo app is meant to be a minimal app, so unit tests were omitted. This was deliberate.

### It runs against a live Firebase project, not the emulators

Simpler to set up, and it avoids emulator-specific behavior. The cost is that you need a real project and a network connection to run anything at all.

## Production gaps

Deliberately left open, and labelled rather than solved.

- **No pagination.** The list is a flat `limit(20)`, so a 21st recipe is unreachable. A real app would page with `startAfter()`.
- **Like documents are left behind on delete.** Deleting a recipe removes the recipe, but every user who liked it keeps a `users/{uid}/likes/{recipeId}` document pointing at a recipe that no longer exists. Clearing those needs a Cloud Functions trigger, which is out of scope for this demo.
- **No rate limiting on generation.** The only brake on the Generate recipe button is that it disables itself while a request is in flight. A real app would limit per user, server-side.
- **No server-side App Check token forwarding.** If the browser forwarded an App Check token with the page request and the server passed it to `initializeServerApp` as `appCheckToken`, and the seed script authenticated with a registered debug token, App Check could be enforced on Firestore and Auth as well. Until then, anyone who copies the public web config out of this repo can read and write Firestore from a script of their own, held back only by the security rules and not by any check that the request came from this app.

## Setup

This section explains how to set up this demo app using your own Firebase project. It serves both as a guide for the curious and as a record of how the Firebase project was configured.

### 1. A Firebase project

The committed `src/app/firebase-config.ts` points at the project this demo was built against. To run it against your own, create a project on the Blaze plan and turn on all four of the following. Then replace the config in `src/app/firebase-config.ts`, and the copy of it in `seed.mjs`, which Node cannot import from the TypeScript module.

- **Authentication** with the Email/Password provider enabled.
- **Cloud Firestore**, Standard edition, production mode.
- **Firebase AI Logic** with the **Agent Platform (formerly Vertex AI)** provider enabled. See below, because this one is easy to get wrong.
- **App Check**, with the web app registered against reCAPTCHA v3. Put the site key in `src/app/firebase-config.ts` as `recaptchaSiteKey`. The secret key stays in the console. Set replay protection to monitoring only, because the app never requests limited-use tokens.

### 2. The AI Logic provider, which is not optional

The app calls `getAI(app, { backend: new AgentPlatformBackend() })`, so enabling only the Gemini Developer API provider is not enough. Enable the Agent Platform provider or generation fails. Two failure signatures are worth recognizing:

- **429 `RESOURCE_EXHAUSTED`, saying prepayment credits are depleted.** The request went to the Gemini Developer API backend, whose prepay balance Google Cloud credit cannot fund. Enable the Agent Platform provider.
- **404 `NOT_FOUND` naming `locations/us-central1`.** The request went through the deprecated `VertexAIBackend`, whose no-argument default is `us-central1`. `AgentPlatformBackend` defaults to `global`, which is what Firebase recommends, and what this app uses.

### 3. App Check enforcement

Enforcement is **on for AI Logic only**. Firestore and Auth are left unenforced on purpose:

- The server renders with `initializeServerApp` and no App Check token, and the reCAPTCHA provider cannot run in Node, so enforcing App Check on Firestore would break every server render.
- `seed.mjs` signs in and writes from Node with no provider either, so enforcing on Auth or Firestore would break the seed script.

AI Logic is called only from the browser, where App Check does run. That is where it matters most. The web config in this repo is public, so anyone can copy it and call this project's Gemini endpoint from a script of their own, and the bill lands on the project. Firestore has security rules to limit what a stranger can do with the same config. AI Logic has no equivalent, so App Check is the only thing requiring those calls to come from this app. Closing the rest of the hole needs server-side token forwarding, which is listed under [Production gaps](#production-gaps).

### 4. Rules and indexes

```bash
firebase deploy --only firestore:rules,firestore:indexes
```

`firebase.json` points at `firestore.rules` and `firestore.indexes.json`, and `.firebaserc` names the project. The two composite indexes are what the cuisine filter needs on top of each sort order.

### 5. Seed data

The seed writes five recipes as its own account, so that account has to exist first. Create it through the app's own Create account button, or in the Authentication console.

```bash
SEED_EMAIL=you@example.com SEED_PASSWORD=... node seed.mjs
```

Re-running deletes that account's own recipes before writing, so it doubles as a reset.

## Running it

```bash
npm ci

# Development server on http://localhost:4200/
npm start

# Production build, then the built SSR server on http://localhost:4000/
npm run build
npm run serve:ssr:recipe-demo
```

### Reviewer note, please read before reporting a failure

**Browse via `localhost`, not `127.0.0.1`.** Two separate things break on the numeric address. The built server rejects it outright with `HTTP 400`, because `angular.json` does not list it among the allowed hosts. And the app sets its App Check debug flag only when `location.hostname` is exactly `localhost`, so on any other host name you would hit real reCAPTCHA rather than a debug token.

**A fresh machine or a fresh browser profile mints a new App Check debug token.** On the first load from `localhost` the console logs one line:

```
Firebase App Check debug token: 00000000-0000-0000-0000-000000000000
```

That token has to be registered in the Firebase console, under App Check, on the web app's Manage debug tokens. Until it is, AI Logic returns `401` and the console repeats `exchangeDebugToken` `403`. Everything except recipe generation keeps working, because enforcement is AI Logic only.

If you would rather not register a token, use the deployed URL above instead.
112 changes: 112 additions & 0 deletions recipe-demo/angular.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"recipe-demo": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"inlineTemplate": false,
"inlineStyle": false,
"skipTests": true,
"changeDetection": "OnPush"
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"server": "src/main.server.ts",
"outputMode": "server",
"security": {
"allowedHosts": [
"localhost",
"raw-sdk--recipe-demo-97859.us-central1.hosted.app",
"raw-sdk--recipe-demo-97859.web.app",
"raw-sdk--recipe-demo-97859.firebaseapp.com",
"raw-sdk-241663837838.us-central1.run.app"
]
},
"ssr": {
"entry": "src/server.ts"
}
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "recipe-demo:build:production"
},
"development": {
"buildTarget": "recipe-demo:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}
4 changes: 4 additions & 0 deletions recipe-demo/apphosting.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Firebase App Hosting backend settings.
# See https://firebase.google.com/docs/app-hosting/configure
runConfig:
minInstances: 0
17 changes: 17 additions & 0 deletions recipe-demo/firebase.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"apphosting": {
"backendId": "raw-sdk",
"rootDir": "/",
"ignore": [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log",
"functions"
]
}
}
21 changes: 21 additions & 0 deletions recipe-demo/firestore.indexes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"indexes": [
{
"collectionGroup": "recipes",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "cuisine", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "recipes",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "cuisine", "order": "ASCENDING" },
{ "fieldPath": "title", "order": "ASCENDING" }
]
}
],
"fieldOverrides": []
}
Loading
Loading