From 5b5506da3c7500d43d12d87431c8aaad1fbb07c9 Mon Sep 17 00:00:00 2001 From: Milos Vasic Date: Wed, 22 Jul 2026 19:26:29 +0500 Subject: [PATCH] docs(credentials): operator config scaffolding for push/Stripe/SMTP/local-LLM tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/credentials/FEATURE_TIER_SETUP.md — a per-tier operator checklist (env var, secret file/path, activation signal cited from real code, honest default when unset) for the four feature tiers main already implements: FCM/APNs push (notification-service), Stripe payments (billing-service), SMTP email (notification-service), and the local LLM client (ai-service). Every variable is verified against the real os.Getenv call that reads it (file:line cited) — no re-implementation, no stubs, docs/config only. Also: appends REDACTED placeholder slots grouped by tier to infrastructure/docker/compose/.env.example; broadens .gitignore to `.env.*` (with `!.env.example` negation), `*.p8`/`*.p12` (APNs auth keys), and a new `scripts/testing/secrets/` scaffold (tracked README + .gitkeep, real secrets ignored) — verified via `git check-ignore --stdin` against every tracked file (no collisions) and `git ls-files` (.env.example stays tracked). Honest finding (Constitution §11.4.6 no-guessing): ai-service's cloud-LLM tier (OpenAI/Anthropic) is NOT implemented — main.go and llmclient.go say so explicitly ("OPERATOR-BLOCKED and deliberately not wired"), confirmed by a repo-wide grep for OPENAI_API_KEY / ANTHROPIC_API_KEY finding no matches. No variable is invented for it; the doc states this plainly instead. Classification: project-specific (§11.4.17) — references this repo's concrete service layout, env var names, and file paths. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015JCrM28hes4SXfJ1cSubJL --- .gitignore | 24 +- docs/credentials/FEATURE_TIER_SETUP.md | 331 +++++++++++++++++++++ infrastructure/docker/compose/.env.example | 59 ++++ scripts/testing/secrets/.gitkeep | 0 scripts/testing/secrets/README.md | 57 ++++ 5 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 docs/credentials/FEATURE_TIER_SETUP.md create mode 100644 scripts/testing/secrets/.gitkeep create mode 100644 scripts/testing/secrets/README.md diff --git a/.gitignore b/.gitignore index 3c2fe2b..2e9d71a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,14 @@ profile.cov go.work go.work.sum -# env file +# env files — secrets, credentials, API keys (§11.4.10 / §11.4.30). Every +# `.env*` variant is ignored EXCEPT the tracked placeholder-value +# `.env.example` (any directory depth — see +# infrastructure/docker/compose/.env.example, negation applies project-wide +# since a slash-free gitignore pattern matches at every depth). .env +.env.* +!.env.example # Firebase setup secrets — service-account JSON, downloaded client SDK # configs (google-services.json / GoogleService-Info.plist). Regenerable @@ -33,6 +39,22 @@ go.work.sum # (§11.4.10 / §11.4.10.A). scripts/firebase/secrets/ +# Per-service local credential files an operator stores as a file instead +# of a shell export (Stripe secret key, SMTP relay creds, etc. — see +# docs/credentials/FEATURE_TIER_SETUP.md). `.example` placeholders + this +# directory's own README.md stay tracked (§11.4.10 exception). +scripts/testing/secrets/* +!scripts/testing/secrets/.gitkeep +!scripts/testing/secrets/README.md +!scripts/testing/secrets/*.example + +# Apple Push Notification service credentials (APNs Authentication Key +# uploaded to Firebase's Cloud Messaging → Apple app configuration; see +# docs/credentials/FEATURE_TIER_SETUP.md "Push notifications" section). +# NEVER git-versioned (§11.4.10). +*.p8 +*.p12 + # Editor/IDE # .idea/ # .vscode/ diff --git a/docs/credentials/FEATURE_TIER_SETUP.md b/docs/credentials/FEATURE_TIER_SETUP.md new file mode 100644 index 0000000..590fe6d --- /dev/null +++ b/docs/credentials/FEATURE_TIER_SETUP.md @@ -0,0 +1,331 @@ +# Feature-tier credential setup — operator checklist + +**Revision:** 1 +**Last modified:** 2026-07-22T00:00:00Z + +## Scope of this document + +Every feature tier described below is **already implemented and merged +on `main`** — real Stripe payment calls, real FCM/APNs push delivery, +real SMTP email delivery, and a real local-LLM client. This document +does **not** implement, stub, wire, or change any of that code. It is +**operator-facing config guidance only**: which environment variable to +set, which credential file it expects, and how to confirm — from real, +cited runtime behaviour — that a tier actually activated once you supply +credentials. + +Every variable named below was verified by reading the real +`os.Getenv` call that consumes it (Constitution §11.4.6 no-guessing — +nothing here is invented). Each citation is `file:line` in this +checkout. Where a tier's activation is observable only through a +notification/subscription **status value** rather than a startup log +line, that is stated explicitly — it is not a gap in this document, it +is the real, current behaviour of the code. + +| Tier | Service | Status | +|---|---|---| +| [Push notifications](#push-notifications-fcm--apns-via-fcm) | notification-service | Implemented | +| [Payments](#payments-stripe) | billing-service | Implemented | +| [Email](#email-smtp) | notification-service | Implemented | +| [AI / LLM](#ai--llm) | ai-service | **Local tier** implemented; **cloud tier NOT implemented** (see below) | + +## A known gap this document does not close + +`infrastructure/docker/compose/docker-compose.yml` does not yet pass +`STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` / `FCM_SERVICE_ACCOUNT_JSON` +/ `FCM_PROJECT_ID` / `SMTP_*` / `AI_LOCAL_PROVIDER_*` through to each +service's `environment:` block (the same gap `docs/guides/BILLING.md` +already documents for Stripe specifically, under "Environment variables +/ key provisioning"). Populating +`infrastructure/docker/compose/.env.example`-derived values in a local +`.env` is therefore **necessary but not yet sufficient** for a +`docker compose up` deployment to pick them up — the compose-level +passthrough (mirroring the existing +`JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY:-}` pattern already used for +billing-service) is a tracked follow-up, not something this document +implements. **Running a service binary directly** (`go run +./cmd/` or the built binary) with the variables exported in +your shell, or sourced from a local file under `scripts/testing/secrets/`, +picks them up immediately — that path does not depend on the +compose-passthrough gap. + +--- + +## Push notifications (FCM + APNs-via-FCM) + +**Real implementation:** +`services/notification-service/internal/delivery/push.go` — a from-scratch +FCM HTTP v1 client (OAuth2 JWT-bearer service-account flow, no vendored +Firebase SDK) that also delivers to iOS devices via FCM's built-in APNs +bridge. + +### Environment variables + +| Variable | Required | Read at | Purpose | +|---|---|---|---| +| `FCM_SERVICE_ACCOUNT_JSON` | Yes, to enable push | `push.go:135` (`PushConfigFromEnv`) | Filesystem path to a Firebase/GCP service-account JSON key with Cloud Messaging send permission. | +| `FCM_PROJECT_ID` | No | `push.go:141` | Overrides the `project_id` embedded in the service-account JSON. Leave unset to use the key's own project. | + +### Which secret file, where + +`FCM_SERVICE_ACCOUNT_JSON` must point at a real Google/Firebase +service-account key JSON — the "Generate new private key" download from +**Firebase Console → Project Settings → Service Accounts**, or +`gcloud iam service-accounts keys create`. + +The project already ships an automation script for this: +`scripts/firebase/firebase_setup.sh`. Run it; by default it writes the +key to `scripts/firebase/secrets/fcm-service-account.json` (override the +output directory with `FIREBASE_SETUP_OUTPUT_DIR`, or the exact key path +with `FIREBASE_SERVICE_ACCOUNT_KEY_PATH`). That directory is **already** +gitignored (`scripts/firebase/secrets/` in the root `.gitignore`) — +point `FCM_SERVICE_ACCOUNT_JSON` at whatever path the script prints. + +If you intend to deliver to **iOS** devices, Firebase's APNs bridge +additionally requires you to upload an **APNs Authentication Key** +(a `.p8` file, plus its Key ID and your Apple Developer Team ID) once, +manually, in **Firebase Console → Project Settings → Cloud Messaging → +Apple app configuration**. `push.go` never reads the `.p8` file itself +(Firebase's own infrastructure holds it and performs the APNs bridge — +see `push.go`'s package doc comment) — but if you keep a local copy of +that key for reference, store it under `scripts/testing/secrets/` (or +anywhere outside the tree) and never commit it; the root `.gitignore` +now covers `*.p8` / `*.p12` project-wide as a defense-in-depth measure. + +### Honest default when unset + +`FCM_SERVICE_ACCOUNT_JSON` unset → `notification-service` starts with +an **unconfigured** `PushSender` (`delivery.NewPushSender()`, the +zero-credential state). Every push notification's `Send` call returns +`ErrPushProviderNotConfigured`, and the notification row's `Status` is +honestly set to `"pending_provider_unconfigured"` +(`handler.go:250-253`) — never a fabricated `"sent"`. + +Set-but-broken (the variable is set but the file cannot be read or +parsed as a valid Google service-account key) is deliberately **not** +collapsed into the same "not configured" bucket — it is a real +misconfiguration and surfaces at startup as: + +``` +[notify] push (FCM) configuration error, falling back to honest not-configured state: +``` + +(`handler.go:52`) — the service still starts (falls back to the +unconfigured sender) rather than crash-looping, but the log line names +exactly what is broken. + +### How to know it activated + +There is **no explicit "push provider configured" startup log line** in +the current code (unlike Stripe below) — this is the real, current +behaviour, not an omission in this document. The positive signals are: + +1. **Absence** of the `[notify] push (FCM) configuration error...` log + line at startup (its presence means the variable is set but broken). +2. A real push notification's `status` field becomes `"sent"` + (`handler.go:268-270`) rather than `"pending_provider_unconfigured"` + after `deliverPush` runs — this is the definitive runtime signal + that FCM's HTTP v1 endpoint actually accepted the message + (`push.go:466-534`, `PushSender.Send`). + +--- + +## Payments (Stripe) + +**Real implementation:** +`services/billing-service/internal/billing/stripe_provider.go` (real +`github.com/stripe/stripe-go/v86` client calls — subscription +create/update/cancel + webhook signature verification). Full detail: +[`docs/guides/BILLING.md`](../guides/BILLING.md). + +### Environment variables + +| Variable | Required | Read at | Purpose | +|---|---|---|---| +| `STRIPE_SECRET_KEY` | No — honest `501` when absent | `env.go:41` (`NewProviderFromEnv`, constant `EnvStripeSecretKey` at `env.go:13`) | Stripe API secret key (`sk_test_...` / `sk_live_...`). | +| `STRIPE_WEBHOOK_SECRET` | No — webhook verification fails closed when absent | `env.go:45` (constant `EnvStripeWebhookSecret` at `env.go:14`) | Stripe webhook endpoint signing secret (`whsec_...`). | + +### Which secret, where + +Both are plain string secrets from the Stripe Dashboard, not files — +export them as shell environment variables, or place them in a local +file under `scripts/testing/secrets/` and `source` it before running the +service locally (see `scripts/testing/secrets/README.md`). Full +walkthrough (creating a Stripe test-mode account, copying the secret +key, creating a recurring Price, configuring the webhook endpoint): +[`docs/guides/BILLING.md` § "Stripe setup (test mode)"](../guides/BILLING.md#stripe-setup-test-mode). + +### Honest default when unset + +`STRIPE_SECRET_KEY` unset → `billing.NewProviderFromEnv()` returns +`(nil, nil)` — not an error. Every subscription-lifecycle-mutating +endpoint (`POST /api/v1/subscriptions`, `PUT .../:id`, +`POST .../:id/cancel`) honestly responds `501 Not Implemented` — never a +fabricated `"active"` status. + +`STRIPE_WEBHOOK_SECRET` may be left empty even when `STRIPE_SECRET_KEY` +is set: subscription create/update/cancel work normally, but +`StripeProvider.VerifyWebhook` always fails closed and rejects every +inbound webhook payload (`stripe_provider.go:268-270`) — there is no +"verify without a secret" mode, since that would mean trusting an +unverified payload. + +### How to know it activated + +`internal/server.New` reads `STRIPE_SECRET_KEY` exactly once at startup +and logs which state the process is in (`server.go:85-92`): + +``` +billing-service: payments provider "stripe" configured — subscription lifecycle calls are REAL +``` + +or, honestly, when unset: + +``` +billing-service: no payments provider configured (STRIPE_SECRET_KEY unset) — subscription-lifecycle-mutating endpoints will respond 501 Not Implemented +``` + +Beyond the log line: a real `POST /api/v1/subscriptions` call returns +`201` with `status` set to the **real** Stripe-reported status (e.g. +`"active"`, `"incomplete"`, `"trialing"`) plus `provider` and +`externalSubscriptionId` in the response body — never a hardcoded +`"active"`. + +--- + +## Email (SMTP) + +**Real implementation:** +`services/notification-service/internal/delivery/email.go` — real +outbound SMTP delivery via the Go standard library `net/smtp` (no +third-party mail library). + +### Environment variables + +| Variable | Required | Read at | Default when unset (if the tier is otherwise enabled) | +|---|---|---|---| +| `SMTP_HOST` | Yes, to enable email | `email.go:55` (`SMTPConfigFromEnv`) | — (unset ⇒ tier disabled entirely) | +| `SMTP_PORT` | No | `email.go:59` | `"25"` | +| `SMTP_FROM` | No | `email.go:63` | `"notifications@localhost"` | +| `SMTP_USERNAME` | No | `email.go:71` | empty (PLAIN auth is used only when a username is set — MailHog/Mailpit-class dev sinks need no auth) | +| `SMTP_PASSWORD` | No | `email.go:72` | empty | + +### Which secret, where + +`SMTP_USERNAME` / `SMTP_PASSWORD` are the credentials your SMTP relay +issues (e.g. an SMTP relay API key from SendGrid/Mailgun/Postmark, or +your own mail server's account). For local development, point +`SMTP_HOST` at a local dev sink (Mailhog, Mailpit, or equivalent) that +requires no auth and leave `SMTP_USERNAME` / `SMTP_PASSWORD` unset. +Store real relay credentials the same way as Stripe's above — a shell +export, or a local file under `scripts/testing/secrets/`. + +### Honest default when unset + +`SMTP_HOST` unset → `notification-service`'s `Handler.emailSender` stays +`nil` (`handler.go:40-42` never sets it). Every email notification's +`status` is honestly set to `"failed"` (`handler.go:203-205`) — no +delivery attempt is made, and the status is never fabricated as `"sent"`. + +### How to know it activated + +**There is no startup log line for SMTP** (unlike push and Stripe above +— this is the real current behaviour of the code, stated honestly +rather than invented). The only observable signal is runtime: create an +email-channel notification and inspect its `status`. `"sent"` +(`handler.go:211-213`, set only after the configured SMTP server +actually accepted the message) means SMTP is genuinely configured and +working; `"failed"` means either it is unconfigured (`SMTP_HOST` unset) +or a real delivery error occurred (both cases collapse to the same +`"failed"` status — the two are distinguished at the source-log level +via the error `smtp not configured: SMTP_HOST is unset` from +`email.go:133`, which is returned to the caller but not itself logged +by this handler; a future improvement could split the two). + +--- + +## AI / LLM + +**Real implementation:** +`services/ai-service/cmd/ai-service/main.go` + +`services/ai-service/internal/llmclient/llmclient.go` — a real HTTP +client (via the `vasic-digital/LLMProvider` generic OpenAI-compatible +adapter) that calls a **local** llama.cpp-compatible LLM server. Full +detail: [`docs/guides/AI_SERVICE.md`](../guides/AI_SERVICE.md). + +### Environment variables — local tier (the only tier that exists) + +| Variable | Required | Read at | Default when unset | +|---|---|---|---| +| `AI_LOCAL_PROVIDER_BASE_URL` | No | `main.go:90` | `http://127.0.0.1:18434/v1/chat/completions` | +| `AI_LOCAL_PROVIDER_MODEL` | No | `main.go:94` | `qwen2.5-1.5b-instruct` | + +Both already have working code-level defaults matching the project's +local HelixLLM llama.cpp smoke-test convention — neither variable is +required for `ai-service` to start and serve real completions against a +local model server. + +### Honest statement: there is NO cloud-LLM tier to configure + +Unlike push/Stripe/email above, **there is no second, cloud-hosted LLM +tier already implemented that merely lacks operator config.** Per +Constitution §11.4.6 (no-guessing — "if a tier's var can't be found, +say so honestly rather than invent"), a repo-wide search for +`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or any other cloud-provider +credential inside `services/ai-service` found **no matches**. `main.go` +states this explicitly: + +> "Local HelixLLM tier only — the cloud tier (OpenAI/Anthropic API +> keys) is OPERATOR-BLOCKED and deliberately not wired" (`main.go:98-99`) + +`llmclient.go`'s package doc comment repeats the same fact +(`llmclient.go:5-7`), and +[`docs/guides/AI_SERVICE.md` § "Local-only posture — why the cloud tier +is off"](../guides/AI_SERVICE.md#local-only-posture--why-the-cloud-tier-is-off) +documents it in full, including that the underlying `LLMProvider` +library the local client is built on ships first-class `openai` / +`anthropic` / `claude` adapters that are simply never invoked here. See +also `docs/CONTINUATION.md` for the tracked follow-up item. + +**Consequence for this document:** there is no `CLOUD_LLM_*` / +`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` variable added to +`infrastructure/docker/compose/.env.example` — adding one would invent +a value the code does not read, which is exactly the fabrication this +project's anti-bluff covenant forbids. When a cloud tier is +implemented, its operator-config additions belong in a follow-up to +this document (and to `.env.example`), not here. + +### How to know the local tier activated + +`ai-service` always logs which local LLM endpoint it is actually using, +whether default or overridden (`main.go:101`): + +``` +ai-service: local LLM provider base_url= model= +``` + +This confirms which endpoint the process will call — it does not by +itself confirm the endpoint is reachable; a real `POST` to the +service's request-creation endpoint against a running local model +server is the positive end-to-end confirmation (see +`docs/guides/AI_SERVICE.md` for the full request/response contract and +failure-mode table). + +--- + +## See also + +- [`docs/guides/BILLING.md`](../guides/BILLING.md) — full Stripe + integration design, API changes, webhook configuration, and test + suite instructions. +- [`docs/guides/AI_SERVICE.md`](../guides/AI_SERVICE.md) — full + local-LLM integration design and the local-only posture rationale. +- `scripts/firebase/firebase_setup.sh` — FCM/APNs service-account + provisioning automation (`--help` for full usage). +- [`scripts/testing/secrets/README.md`](../../scripts/testing/secrets/README.md) + — where to stage local credential files safely (gitignored). +- [`infrastructure/docker/compose/.env.example`](../../infrastructure/docker/compose/.env.example) + — the tracked placeholder template these variables are documented + against. +- `docs/CONTINUATION.md` — live project state, including the tracked + cloud-LLM and compose-passthrough follow-up items. diff --git a/infrastructure/docker/compose/.env.example b/infrastructure/docker/compose/.env.example index f5ced8b..1951aaf 100644 --- a/infrastructure/docker/compose/.env.example +++ b/infrastructure/docker/compose/.env.example @@ -21,3 +21,62 @@ KAFKA_BROKERS=kafka:9092 # ENVIRONMENT=production # JWT_PRIVATE_KEY= # JWT_PUBLIC_KEY= + +# ----------------------------------------------------------------------- +# Feature-tier operator credentials (§11.4.10 / §11.4.30). Every value +# below is a REDACTED placeholder — populate the real value ONLY in a +# local, gitignored .env, never in this tracked .env.example file. Every +# variable is verified against the real os.Getenv call that reads it (no +# invented values, §11.4.6); each tier is ALREADY implemented on main and +# honestly reports "not configured" when its variable is unset — no +# fabricated success either way. Full per-tier setup + activation +# checklist: docs/credentials/FEATURE_TIER_SETUP.md +# +# NOTE: docker-compose.yml does not yet pass these through to each +# service's `environment:` block (mirrors the existing, already- +# documented STRIPE_SECRET_KEY gap in docs/guides/BILLING.md) — populating +# them here is necessary but not yet sufficient for a `docker compose up` +# deployment; see docs/credentials/FEATURE_TIER_SETUP.md for the current +# honest status of that gap and how to work around it locally. +# ----------------------------------------------------------------------- + +# --- Push notifications (FCM + APNs-via-FCM) — notification-service ---- +# Read by services/notification-service/internal/delivery/push.go +# (PushConfigFromEnv, push.go:135,141). Unset FCM_SERVICE_ACCOUNT_JSON => +# every push notification honestly gets status "pending_provider_unconfigured" +# (handler.go:250-253) — never a fabricated "sent". +# FCM_SERVICE_ACCOUNT_JSON= +# FCM_PROJECT_ID= + +# --- Payments (Stripe) — billing-service -------------------------------- +# Read by services/billing-service/internal/billing/env.go +# (NewProviderFromEnv, env.go:41,45). Unset STRIPE_SECRET_KEY => every +# subscription-lifecycle-mutating endpoint honestly responds 501 "payments +# provider not configured" (server.go:91) — never a fabricated "active". +# STRIPE_SECRET_KEY=sk_test_REDACTED +# STRIPE_WEBHOOK_SECRET=whsec_REDACTED + +# --- Email (SMTP) — notification-service -------------------------------- +# Read by services/notification-service/internal/delivery/email.go +# (SMTPConfigFromEnv, email.go:55,59,63,71,72). Unset SMTP_HOST => every +# email notification honestly gets status "failed" (handler.go:203-205, +# no delivery attempt is made) — never a fabricated "sent". +# SMTP_HOST= +# SMTP_PORT=25 +# SMTP_FROM=notifications@localhost +# SMTP_USERNAME= +# SMTP_PASSWORD= + +# --- AI / LLM (local HelixLLM tier ONLY) — ai-service ------------------- +# Read by services/ai-service/cmd/ai-service/main.go (main.go:90,94). Both +# already have working code-level defaults (a local llama.cpp-compatible +# server at 127.0.0.1:18434) so neither is REQUIRED. +# AI_LOCAL_PROVIDER_BASE_URL=http://127.0.0.1:18434/v1/chat/completions +# AI_LOCAL_PROVIDER_MODEL=qwen2.5-1.5b-instruct +# +# HONEST NOTE (§11.4.6 no-guessing): there is NO cloud-LLM (OpenAI/ +# Anthropic) tier to configure here. ai-service does not read +# OPENAI_API_KEY / ANTHROPIC_API_KEY or any cloud-provider credential — +# that tier is explicitly Operator-blocked and UNIMPLEMENTED (see +# docs/guides/AI_SERVICE.md "Local-only posture — why the cloud tier is +# off" and docs/CONTINUATION.md). No variable is added here for it. diff --git a/scripts/testing/secrets/.gitkeep b/scripts/testing/secrets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/testing/secrets/README.md b/scripts/testing/secrets/README.md new file mode 100644 index 0000000..a2ee4c7 --- /dev/null +++ b/scripts/testing/secrets/README.md @@ -0,0 +1,57 @@ +# scripts/testing/secrets/ + +Local, operator-owned credential files for the feature tiers this project +already implements (push, payments, email — see +[`docs/credentials/FEATURE_TIER_SETUP.md`](../../../docs/credentials/FEATURE_TIER_SETUP.md)), +per the Constitution §11.4.10 credentials-handling mandate ("Per-service +file separation limits blast radius"). + +This directory is **gitignored** except for this `README.md`, a +`.gitkeep` (so the empty directory itself stays tracked), and any +`*.example` placeholder files — real credentials placed here are +**never** committed. See the root `.gitignore` `scripts/testing/secrets/*` +block. + +## What goes here + +Any per-service credential an operator prefers to keep as a local file +rather than a shell-exported environment variable — for example, a +scratch copy of a Stripe **test-mode** secret key used by a local +scripting session, or SMTP relay credentials for a local test harness. +None of the services in this repository read a file from this directory +directly — every real credential is consumed via the environment +variables documented in +[`docs/credentials/FEATURE_TIER_SETUP.md`](../../../docs/credentials/FEATURE_TIER_SETUP.md). +This directory exists as a safe, pre-gitignored place to *stage* those +values locally (e.g. `source scripts/testing/secrets/stripe.env` before +running a manual test) instead of leaving them in shell history or an +untracked-but-un-ignored stray file. + +The Firebase/FCM service-account JSON has its **own** dedicated, +already-gitignored location — `scripts/firebase/secrets/` (populated by +`scripts/firebase/firebase_setup.sh`) — do not duplicate it here. + +## Rules (Constitution §11.4.10 / §11.4.30) + +- `chmod 600` every credential file you place here; this directory + itself should be `chmod 700`. +- **Never** commit a real secret. Review `git status` / `git diff + --staged` before every commit (§11.4.30 pre-commit attention) — a + tracked file matching a secret pattern despite the `.gitignore` line + is a violation of equal severity to a missing ignore rule. +- Before storing any operator-supplied credential here (or anywhere), + audit for prior accidental leaks per Constitution §11.4.10.A + (`git ls-files | xargs grep -l ` and + `git log -S --all --source --remotes`) **before** persisting + it. +- Rotate immediately on any suspected leak. + +## See also + +- [`docs/credentials/FEATURE_TIER_SETUP.md`](../../../docs/credentials/FEATURE_TIER_SETUP.md) + — the per-tier operator setup checklist (which env var, which file, + how to confirm it activated). +- [`infrastructure/docker/compose/.env.example`](../../../infrastructure/docker/compose/.env.example) + — the tracked placeholder template for compose-based local bring-up. +- `scripts/firebase/firebase_setup.sh` — the FCM/APNs service-account + provisioning script (writes to `scripts/firebase/secrets/`, not here).