docs: refactor GEMINI.md -> AGENTS.md, update user_creation docs and fix code quality issues

This commit is contained in:
Moritz Graf 2026-04-26 18:36:59 +02:00
parent 08c7ef4660
commit a16ebd641e
4 changed files with 95 additions and 12 deletions

64
AGENTS.md Normal file
View File

@ -0,0 +1,64 @@
# AGENTS.md - Haumdaucher Project Handbook
## 🚨 Rules
**1. Infrastructure as Code is rule #1.** Manual creation of resources (e.g., via `gcloud` or Console) is forbidden. The use of Terraform/Tofu is mandatory.
**2. Agental Protocol (Git & Deployment):**
- Never perform `git add` or `./deploy.sh` before the user has formally accepted the **Walkthrough** artifact.
- Once accepted, the agent is responsible for committing changes and triggering the deployment script.
This document serves as the "Source of Truth" for the Haumdaucher website. It outlines the design principles, technical architecture, and specialized features to ensure consistent future development.
## 🦢 Project Essence
**Haumdaucher** is a community project from Regensburg, Germany. The website represents the "HAUMDAUCHER Wurst und Spezialitäten n.e.V." (nicht eingetragener Verein). It is designed to be humorous, culturally rich, and technically "surprising."
## 🎨 Design Principles
- **Vibrant Aesthetics**: Each theme must feel like a completely different app.
- **Glassmorphism**: Use `backdrop-filter` and semi-transparent backgrounds for a premium feel.
- **Micro-interactions**: Subtle entrance animations and consistent hover states.
- **Accessibility**: Mobile-first design with safe-area support for PWA usage on iOS/Android.
## ⚖️ Legal & Compliance (n.e.V.)
Maintaining the legal section is critical for German compliance (§ 5 DDG).
- **Entity Type**: The club is a **nicht eingetragener Verein (n.e.V.)**. Do NOT add Registry data (Registergericht/Nummer).
- **Mandatory Impressum Fields**:
- Full Name: `HAUMDAUCHER Wurst und Spezialitäten n.e.V.`
- Vertretungsberechtigter: `Moritz Graf (1. Vorstand)`
- Ladungsfähige Anschrift: `Grabengasse 7, 93059 Regensburg`
- Kontakt: `Telefon: 094183065717, E-Mail: info@haumdaucher.de`
- **Privacy (DSGVO)**: The website uses **Google Firebase Authentication**. The privacy policy must disclose the data processing by Google Ireland Limited and the US data transfer details.
## 🛠 Technical Specifications
- **Framework**: Vue 3 (Composition API) + Vite + TypeScript.
- **State Management**: Centralized in `App.vue` using standard `ref` hooks.
- **Theming System**:
- Driven by `data-theme` attribute on `:root`.
- Defined in `src/assets/styles/global.css`.
- Themes: `Classic` (Light), `Dark` (Premium Charcoal/Gold), `NAT` (Boar Easter Egg).
- **Localization**:
- Centralized in `src/locales/i18n.ts`.
- Language: `de` (Standard German) only.
## 🚢 Deployment & DevOps
Deployment is automated via the `./deploy.sh` script.
**Workflow Details:**
1. **Cloud Sync**: Script ensures the `haumdaucher` namespace exists in Kubernetes.
2. **Config Extraction**: Fetches Firebase credentials directly from Terraform outputs (`terraform output -json firebase_config`).
3. **Build Pipeline**: Docker build passes Firebase credentials as `--build-arg` (VITE_FIREBASE_*).
4. **Distribution**: Pushes the image to `registry.haumdaucher.de/haumdaucher-website:latest`.
5. **K8s Update**: Applies `k8s-manifests.yaml` and triggers a `kubectl rollout restart` to fetch the new image.
## 🤖 Sub-module Documentation
Certain independent sub-modules contain their own highly specific operational guidelines. Always consult them when working in those directories:
- **`user_creation/AGENTS.md`**: Contains critical safeguards and architectural details for the declarative Google Apps Script that provisions Firebase users from Google Sheets.
## 🕹 The Haumdaucher Game
- **Engine**: HTML5 Canvas rendering. Game style changes dynamically based on the site's active theme.
- **Unlocking NAT Mode**:
- **Natural**: Reach Level 10.
- **Backdoor**: Single 1x1 pixel in the bottom-left corner. Type `nat mode` into the prompt.
## 📝 Ongoing Maintenance
- **Assets**: Static images should be placed in `public/images/`.
- **Style Overrides**: Mobile-first approach is mandatory. Always test with `max-width: 375px`.

View File

@ -19,4 +19,5 @@ This document provides context for AI agents operating on the `user_creation` in
- Only dispatch the admin summary email (to `CONFIG.ADMIN_EMAIL`) if a mutation occurred. If the target state and current state are perfectly synchronized, exit silently to prevent inbox pollution.
## Implementation Details
- **Email/Password Strategy**: The user requested that we do NOT send out official welcome/password reset emails during the initial implementation to avoid spamming end users. We will generate a highly secure random password locally during the `accounts:signUp` request. The admin will manually trigger password resets later when they are ready.
- **Email/Password Strategy**: We generate a highly secure random password locally during the `accounts` creation request. `CONFIG.SEND_EMAIL_ON_CREATION` controls whether password reset emails are sent automatically.
- **Custom Action URL**: The Firebase Password Reset templates have been modified via the Console to point to the Vue.js app (`https://haumdaucher.de/`). The frontend `App.vue` intercepts `?mode=resetPassword` and displays `PasswordReset.vue`. Do NOT change this logic without considering the UI impact.

View File

@ -7,7 +7,7 @@ It is designed to run silently and declaratively alongside the `mail_forwarding`
## Architecture
- **Environment**: Google Apps Script (Standalone).
- **Authentication**: Native Google Cloud Platform (GCP) linking. The script authenticates via `ScriptApp.getOAuthToken()` using the underlying GCP project's identity, avoiding hardcoded API keys.
- **API**: Google Identity Toolkit REST API (`accounts:batchGet`, `accounts:signUp`, `accounts:update`).
- **API**: Google Identity Toolkit REST API (`accounts:query`, `accounts`, `accounts:update`, `accounts:sendOobCode`).
- **Triggers**: `onFormSubmit` (for real-time form entries) and `onChange` (for manual sheet edits).
## Declarative Logic
@ -35,10 +35,12 @@ If you modify the code or push a new version, you must reinstall the background
*(Note: `clasp push` only updates code, it does not update running triggers).*
### 3. Dry Run Mode
By default, the code is set to `DRY_RUN: true`. It will read states and print its intended actions to the Execution Logs, but will NOT mutate Firebase data.
To activate:
1. Change `DRY_RUN: false` in `src/Code.js`.
2. Run `clasp push`.
The code natively supports a `DRY_RUN` flag in `CONFIG`. When set to `true`, it will read states and print its intended actions to the Execution Logs, but will NOT mutate Firebase data.
Currently, this is configured for production (`DRY_RUN: false`).
### 4. Admin Reporting
The script will send an email to `moritz@haumdaucher.de` **only if** state changes occurred (creating, disabling, or re-enabling a user). If no changes are needed, it remains completely silent.
The script will send an email to `info@haumdaucher.de` **only if** state changes occurred (creating, disabling, or re-enabling a user). If no changes are needed, it remains completely silent.
### 5. Email Templates & Custom Actions
The Identity Platform email templates (Password Reset, etc.) are NOT managed via Terraform due to provider limitations. They are stored in `email_templates.md` and must be manually copy-pasted into the Firebase Console.
The action URL for Password Resets has been customized to point to the main Vue.js frontend (`https://haumdaucher.de/`), which intercepts the `mode=resetPassword` parameter and displays the custom `PasswordReset.vue` component to maintain the club's aesthetic.

View File

@ -148,6 +148,9 @@ function reconcileUsers(desiredEmails, currentState) {
const token = ScriptApp.getOAuthToken();
const executedActions = [];
// Build a Set for O(1) lookup performance (avoids O(n²) with .includes())
const desiredSet = new Set(desiredEmails);
// 1. Check for Creations and Re-enables
for (const email of desiredEmails) {
const fbUser = currentState[email];
@ -164,6 +167,8 @@ function reconcileUsers(desiredEmails, currentState) {
sendPasswordReset(email, token);
executedActions.push(`Sent password reset email to: ${email}`);
}
} else {
executedActions.push(`[ERROR] Failed to create user: ${email}`);
}
} else {
executedActions.push(`[DRY RUN] Would create user: ${email}`);
@ -173,7 +178,11 @@ function reconcileUsers(desiredEmails, currentState) {
console.log(`[ACTION: RE-ENABLE] User is disabled in Firebase: ${email}`);
if (!CONFIG.DRY_RUN) {
const success = updateUserStatus(fbUser.localId, false, token);
if (success) executedActions.push(`Re-enabled user: ${email}`);
if (success) {
executedActions.push(`Re-enabled user: ${email}`);
} else {
executedActions.push(`[ERROR] Failed to re-enable user: ${email}`);
}
} else {
executedActions.push(`[DRY RUN] Would re-enable user: ${email}`);
}
@ -182,12 +191,16 @@ function reconcileUsers(desiredEmails, currentState) {
// 2. Check for Disablements
for (const [email, fbUser] of Object.entries(currentState)) {
if (!desiredEmails.includes(email) && !fbUser.disabled) {
if (!desiredSet.has(email) && !fbUser.disabled) {
// Exists in Firebase, but NOT in sheet -> DISABLE (Soft Delete)
console.log(`[ACTION: DISABLE] User not in sheet: ${email}`);
if (!CONFIG.DRY_RUN) {
const success = updateUserStatus(fbUser.localId, true, token);
if (success) executedActions.push(`Disabled user: ${email}`);
if (success) {
executedActions.push(`Disabled user: ${email}`);
} else {
executedActions.push(`[ERROR] Failed to disable user: ${email}`);
}
} else {
executedActions.push(`[DRY RUN] Would disable user: ${email}`);
}
@ -278,13 +291,16 @@ function sendPasswordReset(email, token) {
}
/**
* Generates a 24-character secure random password.
* Generates a 24-character cryptographically secure random password.
* Uses Utilities.getSecureRandomBytes() instead of Math.random() for security.
*/
function generateSecurePassword() {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?";
const bytes = Utilities.getSecureRandomBytes(24);
let pwd = "";
for (let i = 0; i < 24; i++) {
pwd += chars.charAt(Math.floor(Math.random() * chars.length));
// Mask to avoid modulo bias: chars.length (88) fits safely in a byte (0-255)
pwd += chars.charAt(bytes[i] % chars.length);
}
return pwd;
}