Skip to content

Commit 0ab6961

Browse files
Merge pull request #100 from CodeForPhilly/feat/chat-redirect
feat(api): /chat Slack workspace redirect (closes #79)
2 parents dbe09c5 + 104ccc1 commit 0ab6961

4 files changed

Lines changed: 276 additions & 0 deletions

File tree

apps/api/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { helpWantedRoutes } from './routes/projects-help-wanted.js';
5757
import { projectMembershipRoutes } from './routes/projects-members.js';
5858
import { previewRoutes } from './routes/preview.js';
5959
import { attachmentRoutes } from './routes/attachments.js';
60+
import { chatRoutes } from './routes/chat.js';
6061
import { samlRoutes } from './routes/saml.js';
6162
import { internalRoutes } from './routes/internal.js';
6263

@@ -194,6 +195,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
194195
await fastify.register(projectMembershipRoutes);
195196
await fastify.register(previewRoutes);
196197
await fastify.register(attachmentRoutes);
198+
await fastify.register(chatRoutes);
197199
await fastify.register(samlRoutes);
198200
await fastify.register(internalRoutes);
199201

apps/api/src/routes/chat.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Chat redirect.
3+
*
4+
* GET /chat → 302 to https://<SLACK_TEAM_HOST>/
5+
* GET /chat?channel=<name> → 302 to https://<SLACK_TEAM_HOST>/channels/<name>
6+
* GET /chat?channel= → fall back to workspace root
7+
* GET /chat?channel=<invalid> → fall back to root + warn log
8+
*
9+
* 302 (temporary) so the destination can flip later without browser caches
10+
* sticking. Channel format matches Project.chatChannel
11+
* (`/^[a-z0-9][a-z0-9_-]{0,40}$/`) — same regex protects against
12+
* open-redirect via URL injection.
13+
*
14+
* Per specs/screens/chat.md.
15+
*/
16+
import type { FastifyInstance } from 'fastify';
17+
18+
const CHANNEL_REGEX = /^[a-z0-9][a-z0-9_-]{0,40}$/;
19+
20+
export async function chatRoutes(fastify: FastifyInstance): Promise<void> {
21+
fastify.get(
22+
'/chat',
23+
{
24+
schema: {
25+
tags: ['chat'],
26+
summary: 'Redirect to the Code for Philly Slack workspace',
27+
querystring: {
28+
type: 'object',
29+
properties: { channel: { type: 'string' } },
30+
additionalProperties: false,
31+
},
32+
},
33+
},
34+
async (request, reply) => {
35+
const slackHost = fastify.config.SLACK_TEAM_HOST;
36+
const root = `https://${slackHost}/`;
37+
38+
const raw = (request.query as { channel?: string }).channel;
39+
// Empty string is spec'd to behave like no channel — fall back to root.
40+
const channel = raw && raw.length > 0 ? raw : null;
41+
42+
if (channel === null) {
43+
return reply
44+
.code(302)
45+
.header('Location', root)
46+
.header('Cache-Control', 'no-cache')
47+
.send();
48+
}
49+
50+
if (!CHANNEL_REGEX.test(channel)) {
51+
fastify.log.warn(
52+
// The encoded value keeps log-injection-style payloads benign.
53+
{ channel: encodeURIComponent(channel) },
54+
'chat redirect: invalid channel format; falling back to root',
55+
);
56+
return reply
57+
.code(302)
58+
.header('Location', root)
59+
.header('Cache-Control', 'no-cache')
60+
.send();
61+
}
62+
63+
return reply
64+
.code(302)
65+
.header('Location', `https://${slackHost}/channels/${channel}`)
66+
.header('Cache-Control', 'no-cache')
67+
.send();
68+
},
69+
);
70+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Tests for GET /chat — Slack-workspace redirect per specs/screens/chat.md.
3+
*/
4+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5+
import type { FastifyInstance } from 'fastify';
6+
import { buildApp } from '../src/app.js';
7+
import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js';
8+
9+
let dataRepo: { path: string; cleanup: () => Promise<void> };
10+
let privateStore: { path: string; cleanup: () => Promise<void> };
11+
let app: FastifyInstance;
12+
13+
beforeAll(async () => {
14+
dataRepo = await createFullDataRepo();
15+
privateStore = await createPrivateStorageDir();
16+
app = await buildApp({
17+
serverOptions: { logger: false },
18+
overrideEnv: {
19+
CFP_DATA_REPO_PATH: dataRepo.path,
20+
STORAGE_BACKEND: 'filesystem',
21+
CFP_PRIVATE_STORAGE_PATH: privateStore.path,
22+
CFP_JWT_SIGNING_KEY: 'test-jwt-signing-key-at-least-32-chars!!',
23+
NODE_ENV: 'test',
24+
},
25+
});
26+
}, 60_000);
27+
28+
afterAll(async () => {
29+
await app.close();
30+
await dataRepo.cleanup();
31+
await privateStore.cleanup();
32+
});
33+
34+
describe('GET /chat', () => {
35+
it('redirects to the Slack workspace root when no channel is given', async () => {
36+
const res = await app.inject({ method: 'GET', url: '/chat' });
37+
expect(res.statusCode).toBe(302);
38+
expect(res.headers.location).toBe('https://codeforphilly.slack.com/');
39+
expect(res.headers['cache-control']).toContain('no-cache');
40+
});
41+
42+
it('deep-links to a valid channel', async () => {
43+
const res = await app.inject({ method: 'GET', url: '/chat?channel=general' });
44+
expect(res.statusCode).toBe(302);
45+
expect(res.headers.location).toBe('https://codeforphilly.slack.com/channels/general');
46+
});
47+
48+
it('accepts hyphens and underscores in the channel name', async () => {
49+
const res = await app.inject({ method: 'GET', url: '/chat?channel=philly_civic-tech' });
50+
expect(res.statusCode).toBe(302);
51+
expect(res.headers.location).toBe('https://codeforphilly.slack.com/channels/philly_civic-tech');
52+
});
53+
54+
it('falls back to root for an empty channel', async () => {
55+
const res = await app.inject({ method: 'GET', url: '/chat?channel=' });
56+
expect(res.statusCode).toBe(302);
57+
expect(res.headers.location).toBe('https://codeforphilly.slack.com/');
58+
});
59+
60+
it('falls back to root for uppercase characters (invalid format)', async () => {
61+
const res = await app.inject({ method: 'GET', url: '/chat?channel=General' });
62+
expect(res.statusCode).toBe(302);
63+
expect(res.headers.location).toBe('https://codeforphilly.slack.com/');
64+
});
65+
66+
it('falls back to root for slashes (path-injection attempt)', async () => {
67+
const res = await app.inject({ method: 'GET', url: '/chat?channel=foo%2Fbar' });
68+
expect(res.statusCode).toBe(302);
69+
expect(res.headers.location).toBe('https://codeforphilly.slack.com/');
70+
});
71+
72+
it('falls back to root for an over-long channel name', async () => {
73+
// 42 chars total (> 41 max per the regex)
74+
const channel = 'a'.repeat(42);
75+
const res = await app.inject({ method: 'GET', url: `/chat?channel=${channel}` });
76+
expect(res.statusCode).toBe(302);
77+
expect(res.headers.location).toBe('https://codeforphilly.slack.com/');
78+
});
79+
80+
it('falls back to root for leading hyphen (invalid first char)', async () => {
81+
const res = await app.inject({ method: 'GET', url: '/chat?channel=-leading-hyphen' });
82+
expect(res.statusCode).toBe(302);
83+
expect(res.headers.location).toBe('https://codeforphilly.slack.com/');
84+
});
85+
86+
it('does not register on /api/chat (only /chat)', async () => {
87+
const res = await app.inject({ method: 'GET', url: '/api/chat' });
88+
expect(res.statusCode).toBe(404);
89+
});
90+
});

plans/chat-redirect.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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

Comments
 (0)