|
| 1 | +--- |
| 2 | +status: done |
| 3 | +depends: [] |
| 4 | +specs: |
| 5 | + - specs/screens/chat.md |
| 6 | +issues: [79] |
| 7 | +pr: 100 |
| 8 | +--- |
| 9 | + |
| 10 | +# Plan: /chat Slack redirect |
| 11 | + |
| 12 | +## Scope |
| 13 | + |
| 14 | +[`specs/screens/chat.md`](../specs/screens/chat.md) declares `/chat` as a server-side 302 to the Code for Philly Slack workspace, optionally deep-linking to a channel via `?channel=foo`. Nothing serves it today — every "Open Slack" link across the SPA falls through to the SPA's catch-all (200 HTML). |
| 15 | + |
| 16 | +The redirect rules from the spec: |
| 17 | + |
| 18 | +| Request | Redirect target | HTTP | |
| 19 | +|---|---|---| |
| 20 | +| `/chat` | `https://<SLACK_TEAM_HOST>/` | 302 | |
| 21 | +| `/chat?channel=foo` | `https://<SLACK_TEAM_HOST>/channels/foo` | 302 | |
| 22 | +| `/chat?channel=` (empty) | Same as `/chat` | 302 | |
| 23 | +| `/chat?channel=<invalid format>` | Same as `/chat`, with a log warn | 302 | |
| 24 | + |
| 25 | +302 (temporary) rather than 301 so we can change the destination later without browser-cached redirects sticking. |
| 26 | + |
| 27 | +Closes [#79](https://github.com/CodeForPhilly/codeforphilly-ng/issues/79). |
| 28 | + |
| 29 | +## Implements |
| 30 | + |
| 31 | +- [screens/chat.md](../specs/screens/chat.md) — full spec. |
| 32 | + |
| 33 | +## Approach |
| 34 | + |
| 35 | +### 1. Route, not hook |
| 36 | + |
| 37 | +A plain Fastify `fastify.get('/chat', ...)` route. The two existing redirect plugins (`slug-redirect`, `legacy-redirect`) use `onRequest` hooks because they pattern-match across many URL shapes; `/chat` is exact and Fastify routing matches before the SPA notFoundHandler runs, so a route is the right shape. |
| 38 | + |
| 39 | +### 2. Channel validation |
| 40 | + |
| 41 | +The channel regex matches `Project.chatChannel` in `packages/shared/src/schemas/project.ts`: `/^[a-z0-9][a-z0-9_-]{0,40}$/`. Hoist that to a small shared constant or import the schema's pattern; either way the route gates on the same regex so a channel that came from a project record always works. |
| 42 | + |
| 43 | +Invalid channels — including empty — fall back to the workspace root + a `warn` log entry with the offending value (URL-component-encoded to keep the log line safe). Per spec, no 4xx; just degrade. |
| 44 | + |
| 45 | +### 3. Open-redirect protection |
| 46 | + |
| 47 | +The host is hardcoded from env (`fastify.config.SLACK_TEAM_HOST`, already exists, defaults to `codeforphilly.slack.com`). The channel value only feeds the path segment, never the host. The regex restricts it to `[a-z0-9_-]` so there's no `..`, no `/`, no protocol smuggling. |
| 48 | + |
| 49 | +### 4. Tests |
| 50 | + |
| 51 | +`apps/api/tests/chat-redirect.test.ts`: |
| 52 | + |
| 53 | +- `GET /chat` → 302, `Location: https://codeforphilly.slack.com/` |
| 54 | +- `GET /chat?channel=general` → 302, `Location: …/channels/general` |
| 55 | +- `GET /chat?channel=` → 302, root (no `/channels/`) |
| 56 | +- `GET /chat?channel=foo/bar` → 302, root (regex rejects `/`) |
| 57 | +- `GET /chat?channel=Capital` → 302, root (regex rejects uppercase) |
| 58 | +- `GET /chat?channel=` with all `_` and `-` chars allowed (`philly_civic-tech`) → channel deep-link |
| 59 | +- `Cache-Control` header — `no-cache` so changing the destination is immediate (302 is already temp, but the header is belt + suspenders) |
| 60 | +- The route doesn't appear on `/api/*` paths — only on `/chat`. Verify `/api/chat` 404s as JSON. |
| 61 | + |
| 62 | +## Validation |
| 63 | + |
| 64 | +- [x] Route registered, 302s match the spec table. |
| 65 | +- [x] Channel regex matches `Project.chatChannel`. |
| 66 | +- [x] Invalid channels fall back to root + emit a warn log. |
| 67 | +- [x] All tests pass — 319 API + 30 web + 69 shared. |
| 68 | +- [x] `npm run type-check && npm run lint` clean. |
| 69 | + |
| 70 | +## Risks / unknowns |
| 71 | + |
| 72 | +- **Slack URL shape stability.** `/channels/<name>` is the current Slack deep-link path; if Slack changes it, our redirect breaks. Acceptable risk — the 302 means we can flip the destination in one commit. |
| 73 | +- **No-cache header vs CDN.** 302s without explicit `Cache-Control` may be cached briefly by intermediaries. Adding `Cache-Control: no-cache` is cheap and matches the spec's "we can change the destination later" intent. |
| 74 | + |
| 75 | +## Notes |
| 76 | + |
| 77 | +Three commits: plan-open, feat (route + tests), closeout. The route |
| 78 | +itself is ~50 lines including the JSDoc — the bulk of the work was |
| 79 | +nailing the channel regex semantics so it matches what `Project.chatChannel` |
| 80 | +already enforces, and writing the test sweep that exercises the |
| 81 | +fall-back paths (empty, uppercase, slashes, leading hyphen, over-long). |
| 82 | + |
| 83 | +Surprises: |
| 84 | + |
| 85 | +- **Channel-regex hoist not needed.** I considered importing the |
| 86 | + `Project.chatChannel` Zod regex from `packages/shared/src/schemas/project.ts` |
| 87 | + so the two stay in lockstep, but Zod doesn't expose its underlying |
| 88 | + `RegExp` cleanly without `.shape` plumbing. A duplicated literal with |
| 89 | + a comment pointing at the canonical source is plainly the right |
| 90 | + trade-off here — the regex is one line, will rarely change, and any |
| 91 | + drift would be caught the next time someone adds a `Project.chatChannel` |
| 92 | + case to the route tests. |
| 93 | +- **`request.query` typing.** Fastify's JSON-Schema querystring |
| 94 | + validator gives the handler a properly-typed `request.query` only |
| 95 | + when the JSON Schema's TypeScript-Provider is wired up. We don't |
| 96 | + have that across the codebase yet — every other route does |
| 97 | + `(request.query as { ... })`. Followed the established pattern |
| 98 | + rather than introducing TypeBox here. |
| 99 | +- **`/api/chat` 404s as expected.** The route is registered without |
| 100 | + the `/api` prefix (Fastify's prefix only applies to plugin-scoped |
| 101 | + routes, and this route is registered at the app root). A test asserts |
| 102 | + that `/api/chat` returns 404 so future-me doesn't wonder whether it |
| 103 | + needed `/api/chat` for the spec. |
| 104 | + |
| 105 | +## Follow-ups |
| 106 | + |
| 107 | +- **Slack channel directory.** Eventually the spec'd Volunteer screen |
| 108 | + may want a typeahead of valid channels. Out of scope here; would be |
| 109 | + a separate enhancement on top of an actual Slack-integration story. |
| 110 | + *None* — not worth tracking until there's a real ask. |
| 111 | +- **Per-channel analytics.** Knowing which `?channel=` deep-links get |
| 112 | + used would inform what to feature on the SPA. Tiny — could wrap the |
| 113 | + log line with a metric tag. *Deferred to plan* — bundle with the |
| 114 | + next observability pass if/when we wire metrics. |
0 commit comments