# Member print storage (GCS) + signed downloads

Each **member** (Google identity) gets a private prefix inside one shared bucket. The marketing site stays on **GitHub Pages**; a tiny **Cloud Function** lists objects and returns **time-limited signed URLs** so the browser never holds service-account keys.

## Object layout (by day)

Use **UTC date** or your pipeline’s canonical calendar date (pick one and keep it consistent):

```text
gs://BUCKET/members/{google_sub}/{YYYY-MM-DD}/gazette.pdf
gs://BUCKET/members/{google_sub}/{YYYY-MM-DD}/gazetteer.pdf
gs://BUCKET/members/{google_sub}/{YYYY-MM-DD}/gazetteer-ws1.pdf
gs://BUCKET/members/{google_sub}/{YYYY-MM-DD}/art-sheet.pdf
```

- `{google_sub}` is the stable `sub` claim from the Google ID token (OIDC subject).
- Upload jobs (Cloud Run, Workflows, GitHub Actions with WIF, etc.) write only under the member’s prefix.
- The Cloud Function **lists and signs only** under `members/{sub}/{date}/` for the verified `sub`.

## Automated setup (gcloud + `castalia.institute`)

Same pattern as Cloudflare DNS in this repo: keep secrets in the sibling **`castalia.institute`** checkout’s **`.env`** (not committed).

**What `gcloud` can fill in:** **`GCP_PROJECT`** (current config), **`GCS_MEMBER_BUCKET`** (pick from `gcloud storage buckets list`), and **`GOOGLE_APPLICATION_CREDENTIALS`** (path after you run **`gcloud iam service-accounts keys create`** — Google never re-shows an existing private key). **`GOOGLE_OAUTH_CLIENT_ID`** is *not* listed by `gcloud`; copy it from **APIs & Services → Credentials** (or open the link printed by the helper below).

From repo root, with `gcloud` already authenticated (`gcloud auth login` or an active account):

```bash
./scripts/gcp-suggest-env-member-print.sh
```

That prints a template plus bucket names, service account emails, and a direct Console URL for OAuth credentials.

1. Copy variable names from **`scripts/env.gcp.example`** into **`../castalia.institute/.env`** and fill values:
   - **`GCP_PROJECT`** — project that owns the bucket and function.
   - **`GOOGLE_APPLICATION_CREDENTIALS`** — absolute path to a **deployer** service account JSON key (activate with `gcloud auth activate-service-account`).
   - **`GCS_MEMBER_BUCKET`** — bucket name only (no `gs://`).
   - **`GOOGLE_OAUTH_CLIENT_ID`** — Web client ID from Google Cloud Console (same as GIS on the site).

2. From this repo root:

   ```bash
   ./scripts/gcp-setup-member-print.sh
   ```

   Preview without changing GCP:

   ```bash
   DRY_RUN=1 ./scripts/gcp-setup-member-print.sh
   ```

   If your **`castalia.institute`** checkout lives elsewhere:

   ```bash
   CASTALIA_INSTITUTE_ROOT=/path/to/castalia.institute ./scripts/gcp-setup-member-print.sh
   ```

The script enables APIs, creates the bucket and runtime service account if missing, binds **`roles/storage.objectViewer`** on the bucket, and deploys **`functions/member_print_files`** (Gen2). On success it prints the **HTTPS function URL** — set that as **`apiBase`** in **`docs/js/member-print-config.js`**.

**Deployer key:** the JSON key you point **`GOOGLE_APPLICATION_CREDENTIALS`** at needs enough IAM to create buckets (or use an existing bucket), deploy Gen2 functions, and grant bucket IAM to the runtime account. For a one-off bootstrap, **Editor** on the project is the blunt option; narrow roles afterward for production.

## 1. Create bucket (example)

```bash
export PROJECT=your-gcp-project
export BUCKET=castalia-gazetteer-member-files

gcloud config set project "$PROJECT"
gcloud storage buckets create "gs://$BUCKET" \
  --location=us-central1 \
  --uniform-bucket-level-access
```

Do **not** make the bucket public. Access is via signed URLs from the function.

## 2. Service account for the Cloud Function

```bash
export SA=member-print-fn@${PROJECT}.iam.gserviceaccount.com

gcloud iam service-accounts create member-print-fn \
  --display-name="Member print files (GCS list+sign)"

gcloud storage buckets add-iam-policy-binding "gs://$BUCKET" \
  --member="serviceAccount:$SA" \
  --role=roles/storage.objectViewer
```

The default runtime identity needs permission to **sign URLs**. Either:

- Grant the function’s SA **Storage Object Viewer** (above) and ensure signing works with the default credentials (Gen2 uses the runtime SA), **or**
- Follow [signed URL + IAM](https://cloud.google.com/storage/docs/access-control/signed-urls) if your org restricts `signBlob`.

If `generate_signed_url` fails in logs, add:

```bash
gcloud iam service-accounts add-iam-policy-binding "$SA" \
  --member="serviceAccount:$SA" \
  --role=roles/iam.serviceAccountTokenCreator
```

(Exact policy depends on org; see Cloud Storage signed URL troubleshooting.)

## 3. OAuth Web client (Google Identity Services)

1. APIs & Services → Credentials → **Create credentials** → **OAuth client ID** → **Web application**.
2. **Authorized JavaScript origins**
   - `https://gazetteer.castalia.institute`
   - `https://castaliainstitute.github.io` (if you use the GitHub fallback)
3. **Authorized redirect URIs** — not required for One Tap / JWT button if you use the GIS JS callback only; add your staging origins as needed.

Copy the **Client ID** string (ends with `.apps.googleusercontent.com`).

## 4. Deploy Cloud Function (Gen2)

From repo root (requires `gcloud` and Cloud Functions API enabled):

```bash
cd functions/member_print_files

gcloud functions deploy member_print_files \
  --gen2 \
  --region=us-central1 \
  --runtime=python312 \
  --entry-point=member_print_files \
  --trigger-http \
  --allow-unauthenticated \
  --set-env-vars="GCS_BUCKET=$BUCKET,GOOGLE_OAUTH_CLIENT_ID=YOUR_CLIENT_ID.apps.googleusercontent.com,CORS_ALLOW_ORIGINS=https://gazetteer.castalia.institute,https://castaliainstitute.github.io"
```

`--allow-unauthenticated` is normal: **authorization is the Google ID token in the POST body**, not IAM on the HTTP trigger.

Note the **HTTPS trigger URL** and set it in `docs/js/member-print-config.js` as `apiBase` (no trailing slash).

## 5. Configure the static site

Edit **`docs/js/member-print-config.js`**:

- `googleClientId` — OAuth Web client ID.
- `apiBase` — Cloud Function URL, e.g. `https://us-central1-PROJECT.cloudfunctions.net/member_print_files`.

Commit and publish `docs/` to GitHub Pages.

## 6. Upload pipeline (your generator)

After you render PDFs for a member and date:

```bash
gcloud storage cp gazette.pdf "gs://$BUCKET/members/$GOOGLE_SUB/$DATE/"
```

Obtain `$GOOGLE_SUB` from your account directory (or from the ID token during onboarding). Never guess another member’s prefix.

## Security checklist

- [ ] Bucket is private (no `allUsers` reader).
- [ ] Function verifies **audience** = your OAuth client ID.
- [ ] Function only lists `members/{verified_sub}/...`.
- [ ] CORS `CORS_ALLOW_ORIGINS` matches real site origins only.
- [ ] Short signed URL TTL (default 45 minutes in code).

## Local testing

```bash
cd functions/member_print_files
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export GCS_BUCKET=...
export GOOGLE_OAUTH_CLIENT_ID=...
functions-framework --target=member_print_files --debug --port=8080
```

POST JSON to `http://localhost:8080` with a real `id_token` from the GIS debug tool or your staging site.
