1# Link shortener on Cloud Run
2
3A 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
6curl -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"}
8curl -i "$URL/l/aZ3k9Qx"
9# 302 location: https://alchemy.run
10curl "$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
25Until 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
34Firestore 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
38Credentials 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
40From the repository root:
41
42```sh
43pnpm install
44cd examples/gcp-cloud-run-api
45pnpm deploy --profile <name>
46```
47
48Docker must be running, because the service is built locally from `main`. Then add the API key:
49
50```sh
51printf 'my-key' | gcloud secrets versions add "<secretId output>" --data-file=-
52```
53
54## Live test
55
56```sh
57ALCHEMY_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
65pnpm destroy --profile <name>
66```
67
68This deletes the service, its IAM grants, the secret with all of its versions, and the database with all of its documents.