| 1 | # Link shortener on Cloud Run |
| 2 | |
| 3 | A public link shortener: one Cloud Run container, Firestore for state, and Secret Manager for the API key that guards writes. Every IAM grant comes from a binding, so nothing is wired by hand. |
| 4 | |
| 5 | ```sh |
| 6 | curl -X POST "$URL/links" -H "x-api-key: my-key" -H 'content-type: application/json' -d '{"url":"https://alchemy.run"}' |
| 7 | # 201 {"code":"aZ3k9Qx","shortUrl":"https://…/l/aZ3k9Qx"} |
| 8 | curl -i "$URL/l/aZ3k9Qx" |
| 9 | # 302 location: https://alchemy.run |
| 10 | curl "$URL/links/aZ3k9Qx" |
| 11 | # 200 {"code":"aZ3k9Qx","url":"https://alchemy.run","clicks":1,…} |
| 12 | ``` |
| 13 | |
| 14 | ## Architecture |
| 15 | |
| 16 | - [`Links`](./src/resources.ts) — named Firestore Native database; one document per link at `links/{code}`. |
| 17 | - [`ApiKey`](./src/resources.ts) — Secret Manager secret holding the key callers send as `x-api-key`. Alchemy creates the secret; adding a version with the value is an operator step. |
| 18 | - [`Api`](./src/Api.ts) — public `GCP.Function` (a Cloud Run service): |
| 19 | - `GET /` — health check. |
| 20 | - `POST /links` — mint a 7-character base62 code for a URL (requires `x-api-key`). |
| 21 | - `GET /l/:code` — `302` to the target and count the click. |
| 22 | - `GET /links/:code` — read the link back. |
| 23 | - `DELETE /links/:code` — retire a code (requires `x-api-key`). |
| 24 | |
| 25 | Until the secret has a version, `POST /links` answers `503` instead of accepting any key. |
| 26 | |
| 27 | ## Bindings and IAM |
| 28 | |
| 29 | | Host | Binding | IAM granted to the host's service account | |
| 30 | | ---- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | |
| 31 | | Api | `GCP.Firestore.ReadWriteDatabase(Links)` + `ReadWriteDatabaseHttp` | `roles/datastore.user` on the project, with an IAM Condition limiting it to `Links` | |
| 32 | | Api | `GCP.SecretManager.ReadSecret(ApiKey)` + `ReadSecretHttp` | `roles/secretmanager.secretAccessor` on the `ApiKey` secret only | |
| 33 | |
| 34 | Firestore databases have no resource-level IAM policy, so the Firestore grant is project-level under an IAM Condition on the database name. It can take a few minutes to propagate after the first deploy; until then writes fail with `500`. |
| 35 | |
| 36 | ## Deploy |
| 37 | |
| 38 | Credentials come from your alchemy profile: run `alchemy profile` once and pick GCP (*Service account JSON* for a key file, or *Stored* for an access token or key kept in `~/.alchemy/credentials`, plus a default region), then deploy with `--profile <name>`. |
| 39 | |
| 40 | From the repository root: |
| 41 | |
| 42 | ```sh |
| 43 | pnpm install |
| 44 | cd examples/gcp-cloud-run-api |
| 45 | pnpm deploy --profile <name> |
| 46 | ``` |
| 47 | |
| 48 | Docker must be running, because the service is built locally from `main`. Then add the API key: |
| 49 | |
| 50 | ```sh |
| 51 | printf 'my-key' | gcloud secrets versions add "<secretId output>" --data-file=- |
| 52 | ``` |
| 53 | |
| 54 | ## Live test |
| 55 | |
| 56 | ```sh |
| 57 | ALCHEMY_PROFILE=<name> bun test --timeout 1200000 |
| 58 | ``` |
| 59 | |
| 60 | [The test](./test/integ.test.ts) deploys the stack, adds a secret version, and checks the health route. It verifies that creates without the right key get `401`, then mints a link, confirms the Firestore document out of band, follows the redirect, checks the click count, and deletes the link. At the end it destroys the stack. |
| 61 | |
| 62 | ## Destroy |
| 63 | |
| 64 | ```sh |
| 65 | pnpm destroy --profile <name> |
| 66 | ``` |
| 67 | |
| 68 | This deletes the service, its IAM grants, the secret with all of its versions, and the database with all of its documents. |