Skip to content

Commit c9c8ccb

Browse files
Merge pull request #92 from CodeForPhilly/feat/slug-history-redirect
feat(api): slug-history 301 redirect handler (closes #80)
2 parents a49b2aa + 1f11681 commit c9c8ccb

10 files changed

Lines changed: 653 additions & 0 deletions

File tree

apps/api/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import reconcilePlugin from './plugins/reconcile.js';
3838
import pushDaemonPlugin from './plugins/push-daemon.js';
3939
import servicesPlugin from './plugins/services.js';
4040
import markdownPlugin from './plugins/markdown.js';
41+
import slugRedirectPlugin from './plugins/slug-redirect.js';
4142
import rateLimitPlugin from './plugins/rate-limit.js';
4243
import idempotencyPlugin from './plugins/idempotency.js';
4344
import sessionMiddlewarePlugin from './auth/middleware.js';
@@ -131,6 +132,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
131132
// ----- 6c. Services (loads in-memory state + FTS, boots after store) -----
132133
await fastify.register(servicesPlugin);
133134
await fastify.register(markdownPlugin);
135+
await fastify.register(slugRedirectPlugin);
134136

135137
// ----- 7. Rate limiting -----
136138
await fastify.register(rateLimitPlugin);
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* Slug-history redirect plugin.
3+
*
4+
* Serves 301s for non-expired SlugHistory entries per
5+
* specs/behaviors/slug-handles.md → "Mutability and redirects". Pattern-
6+
* matches the slug-bearing SPA paths on every non-/api/* GET; for live-
7+
* missing slugs that have a SlugHistory entry, redirects to the canonical
8+
* URL with the suffix preserved.
9+
*
10+
* Spec edge cases handled:
11+
* - **Live wins** — if `oldSlug` is currently a live entity of the same
12+
* type (someone took the freed slug), no redirect.
13+
* - **Multi-hop chains** — A → B → C resolves to C in one response via
14+
* in-process chain follow, capped at MAX_HOPS to short-circuit
15+
* pathological inputs.
16+
* - **Expired entries** — past-`expiresAt` records are dropped at index
17+
* time (`indexSlugHistory`) so the lookup naturally misses.
18+
* - **Tag namespace** — `/tags/:namespace/:slug` rewrites the slug while
19+
* preserving the namespace; the SlugHistory key is `tag:<slug>`
20+
* regardless of namespace (matches the schema's lookup shape).
21+
*
22+
* Plugin order: registered after `services` (decorates `inMemoryState`)
23+
* and before `static-web` (which owns the SPA fallthrough notFoundHandler).
24+
*/
25+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
26+
import fp from 'fastify-plugin';
27+
28+
import type { SlugHistory } from '@cfp/shared/schemas';
29+
30+
import { slugHistoryKey, type InMemoryState } from '../store/memory/state.js';
31+
32+
const MAX_HOPS = 8;
33+
34+
type EntityType = SlugHistory['entityType'];
35+
36+
/**
37+
* Pattern descriptor for a slug-bearing path. Each pattern declares the
38+
* regex that extracts the slug component(s) and a function that rebuilds
39+
* the URL once we've resolved any renames.
40+
*/
41+
interface RoutePattern {
42+
/** Entity type that the slug-history lookup keys on. */
43+
readonly entityType: EntityType;
44+
/**
45+
* Regex against `path` (origin-relative; query stripped). Must contain a
46+
* single capture group for the slug component, except `tag` which captures
47+
* (namespace, slug).
48+
*/
49+
readonly match: RegExp;
50+
/** Live-entity check — returns the canonical slug if this slug is live, else null. */
51+
readonly liveIndex: (state: InMemoryState, slug: string) => boolean;
52+
/** Rebuild the path with the resolved slug substituted in place of the original. */
53+
readonly rebuild: (match: RegExpExecArray, resolvedSlug: string) => string;
54+
}
55+
56+
const patterns: readonly RoutePattern[] = [
57+
// /projects/<slug>/buzz/<buzzSlug>... — both legs are renamable. The deeper
58+
// match runs first so we attempt to resolve both buzz and project; either
59+
// can independently redirect.
60+
// (Currently no slug-history writer for buzz, but the spec includes 'buzz'
61+
// in SlugHistory.entityType so the plumbing supports it.)
62+
{
63+
entityType: 'buzz',
64+
match: /^\/projects\/([^/]+)\/buzz\/([^/]+)(\/.*)?$/,
65+
liveIndex: (state, slug) => {
66+
// Buzz slugs are keyed by `${projectId}:${buzzSlug}` in the live index;
67+
// since we don't have the projectId at this point cheaply, treat the
68+
// slug as missing-from-live whenever a slug-history record exists.
69+
// This is the spec-permitted behavior: if a freed buzz slug was taken
70+
// by a different project, the slug-history record points away anyway.
71+
void state;
72+
void slug;
73+
return false;
74+
},
75+
rebuild: (m, resolved) => `/projects/${m[1]}/buzz/${resolved}${m[3] ?? ''}`,
76+
},
77+
{
78+
entityType: 'project',
79+
match: /^\/projects\/([^/]+)(\/.*)?$/,
80+
liveIndex: (state, slug) => state.projectIdBySlug.has(slug),
81+
rebuild: (m, resolved) => `/projects/${resolved}${m[2] ?? ''}`,
82+
},
83+
{
84+
entityType: 'person',
85+
match: /^\/members\/([^/]+)(\/.*)?$/,
86+
liveIndex: (state, slug) => state.personIdBySlug.has(slug),
87+
rebuild: (m, resolved) => `/members/${resolved}${m[2] ?? ''}`,
88+
},
89+
{
90+
entityType: 'tag',
91+
// Capture namespace + slug; namespace stays unchanged in the rebuild.
92+
match: /^\/tags\/([^/]+)\/([^/]+)(\/.*)?$/,
93+
liveIndex: (state, slug) => {
94+
// Tags are uniquely keyed by `(namespace, slug)`; we look up `tag:<slug>`
95+
// in slug-history regardless of namespace. Live-check checks any
96+
// namespace match — if the slug is in use by ANY namespace it counts
97+
// as "live wins". Tags rarely collide across namespaces so this is
98+
// conservative.
99+
for (const handle of state.tagIdByHandle.keys()) {
100+
if (handle.endsWith(`.${slug}`)) return true;
101+
}
102+
return false;
103+
},
104+
rebuild: (m, resolved) => `/tags/${m[1]}/${resolved}${m[3] ?? ''}`,
105+
},
106+
];
107+
108+
/**
109+
* Follow the slug-history chain in-process up to MAX_HOPS, stopping at the
110+
* first slug that is live (or absent from slug-history). Returns null when
111+
* no rewriting is needed (slug already live, or no slug-history entry, or
112+
* the chain bottoms out at the same slug).
113+
*/
114+
function resolveSlug(
115+
state: InMemoryState,
116+
pattern: RoutePattern,
117+
startSlug: string,
118+
): string | null {
119+
// Live first — never redirect away from a slug that's currently a real entity.
120+
if (pattern.liveIndex(state, startSlug)) return null;
121+
122+
let current = startSlug;
123+
for (let hop = 0; hop < MAX_HOPS; hop++) {
124+
const entry = state.slugHistory.get(slugHistoryKey(pattern.entityType, current));
125+
if (!entry) {
126+
// No more slug-history to follow. Return null if we never moved.
127+
return current === startSlug ? null : current;
128+
}
129+
if (entry.newSlug === current) {
130+
// Defensive: pathological self-loop.
131+
return current === startSlug ? null : current;
132+
}
133+
current = entry.newSlug;
134+
// If the chain has reached a live slug, we're done — return it.
135+
if (pattern.liveIndex(state, current)) return current;
136+
}
137+
// Chain too long — log + bail. The first hop's destination is what we return.
138+
return current === startSlug ? null : current;
139+
}
140+
141+
async function slugRedirectPlugin(fastify: FastifyInstance): Promise<void> {
142+
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
143+
if (request.method !== 'GET' && request.method !== 'HEAD') return;
144+
// Strip query before pattern-matching; preserve it for the rebuilt URL.
145+
const url = request.url;
146+
if (url.startsWith('/api/')) return;
147+
const queryIdx = url.indexOf('?');
148+
const path = queryIdx === -1 ? url : url.slice(0, queryIdx);
149+
const query = queryIdx === -1 ? '' : url.slice(queryIdx);
150+
151+
const state = fastify.inMemoryState;
152+
153+
for (const pattern of patterns) {
154+
const m = pattern.match.exec(path);
155+
if (!m) continue;
156+
// For the tag pattern the captured slug is in group 2; everywhere else
157+
// it's group 1. (Buzz pattern's renamable leg is also group 2.)
158+
const slugGroup = pattern.entityType === 'tag' || pattern.entityType === 'buzz' ? 2 : 1;
159+
const slug = m[slugGroup];
160+
if (!slug) continue;
161+
162+
const resolved = resolveSlug(state, pattern, slug);
163+
if (!resolved) continue;
164+
165+
const newPath = pattern.rebuild(m, resolved);
166+
const target = newPath + query;
167+
// 5-minute cache: the redirect itself may expire when the 90-day
168+
// window does; a short cache balances perf with not lying to clients
169+
// about a permanent-looking redirect.
170+
await reply
171+
.code(301)
172+
.header('Location', target)
173+
.header('Cache-Control', 'public, max-age=300')
174+
.send();
175+
return;
176+
}
177+
});
178+
}
179+
180+
export default fp(slugRedirectPlugin, {
181+
name: 'slug-redirect',
182+
dependencies: ['services'],
183+
});

apps/api/src/services/account-claim.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,9 @@ export class AccountClaimService {
684684
await tx.public['slug-history'].upsert(slugHistory);
685685

686686
// Hard-delete the requester Person -----------------------------------
687+
// (the slugHistory record is replayed into the in-memory state via
688+
// MergeApply below — same lockstep guarantee the other state-apply
689+
// ops get.)
687690

688691
await tx.public.people.delete(requester);
689692

@@ -711,6 +714,7 @@ export class AccountClaimService {
711714
updatedPerson: updatedClaimed,
712715
deletedRequesterPersonId: requester.id,
713716
deletedRequesterSlug: requester.slug,
717+
slugHistory,
714718
reMemberships,
715719
removedMemberships,
716720
reUpdates,
@@ -730,6 +734,7 @@ interface MergeApplyInput {
730734
readonly updatedPerson: Person;
731735
readonly deletedRequesterPersonId: string;
732736
readonly deletedRequesterSlug: string;
737+
readonly slugHistory: SlugHistory;
733738
readonly reMemberships: ProjectMembership[];
734739
readonly removedMemberships: ProjectMembership[];
735740
readonly reUpdates: ProjectUpdate[];
@@ -760,6 +765,7 @@ export class MergeApply {
760765
replay(stateApply: StateApply): void {
761766
const i = this.#input;
762767
stateApply.upsertPerson(i.updatedPerson);
768+
stateApply.upsertSlugHistory(i.slugHistory);
763769
for (const m of i.reMemberships) stateApply.upsertMembership(m);
764770
for (const m of i.removedMemberships) stateApply.removeMembership(m);
765771
for (const u of i.reUpdates) stateApply.upsertProjectUpdate(u);

apps/api/src/services/person.write.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export class PersonWriteService {
125125
};
126126
await tx.public['slug-history'].upsert(history);
127127
stateApply.renamePersonSlug(existing.id, existing.slug, newSlug);
128+
stateApply.upsertSlugHistory(history);
128129
}
129130

130131
await tx.public.people.upsert(updated);

apps/api/src/services/project.write.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ export class ProjectWriteService {
302302
};
303303
await tx.public['slug-history'].upsert(history);
304304
stateApply.renameProjectSlug(existing.id, slugRename.oldSlug, slugRename.newSlug);
305+
stateApply.upsertSlugHistory(history);
305306
}
306307

307308
await tx.public.projects.upsert(updated);

apps/api/src/store/memory/loader.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
indexProject,
1515
indexProjectBuzz,
1616
indexProjectUpdate,
17+
indexSlugHistory,
1718
indexTag,
1819
indexTagAssignment,
1920
type InMemoryState,
@@ -32,6 +33,7 @@ export async function loadInMemoryState(publicStore: PublicStore): Promise<InMem
3233
buzzes,
3334
roles,
3435
interests,
36+
slugHistoryRecords,
3537
] = await Promise.all([
3638
publicStore.projects.queryAll(),
3739
publicStore.people.queryAll(),
@@ -42,6 +44,7 @@ export async function loadInMemoryState(publicStore: PublicStore): Promise<InMem
4244
publicStore['project-buzz'].queryAll(),
4345
publicStore['help-wanted-roles'].queryAll(),
4446
publicStore['help-wanted-interest'].queryAll(),
47+
publicStore['slug-history'].queryAll(),
4548
]);
4649

4750
for (const p of projects) indexProject(state, p);
@@ -53,6 +56,11 @@ export async function loadInMemoryState(publicStore: PublicStore): Promise<InMem
5356
for (const b of buzzes) indexProjectBuzz(state, b);
5457
for (const r of roles) indexHelpWantedRole(state, r);
5558
for (const i of interests) indexHelpWantedInterest(state, i);
59+
// Slug-history is filtered for expiry inside indexSlugHistory — records
60+
// past their 90-day window are skipped (the sheet retains them until a
61+
// separate sweeper purges; this is the read-path defense).
62+
const bootNow = new Date();
63+
for (const r of slugHistoryRecords) indexSlugHistory(state, r, bootNow);
5664

5765
return state;
5866
}

apps/api/src/store/memory/state.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
ProjectBuzz,
1616
ProjectMembership,
1717
ProjectUpdate,
18+
SlugHistory,
1819
Tag,
1920
TagAssignment,
2021
} from '@cfp/shared/schemas';
@@ -78,6 +79,20 @@ export interface InMemoryState {
7879
interestByRoleAndPerson: Map<string, string>;
7980
/** roleId → Set<interestId> */
8081
interestByRole: Map<string, Set<string>>;
82+
83+
/**
84+
* Slug-history index, keyed by `${entityType}:${oldSlug}` → newSlug + expiry.
85+
* Populated at boot from non-expired SlugHistory records; updated in
86+
* lockstep with rename writes via StateApply.upsertSlugHistory. The
87+
* `slug-redirect` plugin reads this on every non-/api/* GET request to
88+
* decide whether to serve a 301 per specs/behaviors/slug-handles.md.
89+
*/
90+
slugHistory: Map<string, { newSlug: string; expiresAt: string }>;
91+
}
92+
93+
/** Compose the slug-history map key. Kept here so call sites stay consistent. */
94+
export function slugHistoryKey(entityType: SlugHistory['entityType'], oldSlug: string): string {
95+
return `${entityType}:${oldSlug}`;
8196
}
8297

8398
export function createEmptyState(): InMemoryState {
@@ -108,9 +123,24 @@ export function createEmptyState(): InMemoryState {
108123
tagAssignmentsByTag: new Map(),
109124
interestByRoleAndPerson: new Map(),
110125
interestByRole: new Map(),
126+
slugHistory: new Map(),
111127
};
112128
}
113129

130+
/**
131+
* Index a SlugHistory record into the in-memory map. Expired records (where
132+
* `expiresAt < now`) are dropped — the periodic sweeper that purges them
133+
* from the gitsheets sheet is a separate concern; this is the read-path
134+
* defense.
135+
*/
136+
export function indexSlugHistory(state: InMemoryState, record: SlugHistory, now: Date = new Date()): void {
137+
if (new Date(record.expiresAt) <= now) return;
138+
state.slugHistory.set(slugHistoryKey(record.entityType, record.oldSlug), {
139+
newSlug: record.newSlug,
140+
expiresAt: record.expiresAt,
141+
});
142+
}
143+
114144
/** Add or replace one project and update its secondary indices. */
115145
export function indexProject(state: InMemoryState, project: Project): void {
116146
const old = state.projects.get(project.id);

apps/api/src/store/state-apply.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
ProjectBuzz,
1616
ProjectMembership,
1717
ProjectUpdate,
18+
SlugHistory,
1819
Tag,
1920
TagAssignment,
2021
} from '@cfp/shared/schemas';
@@ -28,6 +29,7 @@ import {
2829
indexProject,
2930
indexProjectBuzz,
3031
indexProjectUpdate,
32+
indexSlugHistory,
3133
indexTag,
3234
indexTagAssignment,
3335
type InMemoryState,
@@ -207,6 +209,16 @@ export class StateApply {
207209
return this;
208210
}
209211

212+
/**
213+
* Mirror a SlugHistory upsert into the in-memory map so the slug-redirect
214+
* plugin sees it on the very next request. Expiry filtering happens inside
215+
* indexSlugHistory — already-expired records are no-ops here.
216+
*/
217+
upsertSlugHistory(record: SlugHistory): this {
218+
this.#ops.push((state) => indexSlugHistory(state, record));
219+
return this;
220+
}
221+
210222
apply(state: InMemoryState, fts: FtsEngine): void {
211223
for (const op of this.#ops) {
212224
op(state, fts);

0 commit comments

Comments
 (0)