Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,14 @@ CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars
# serves the SPA as a fallthrough for non-/api/* routes (single-image
# deploy per specs/architecture.md). Leave unset in dev — Vite owns 5173.
# CFP_WEB_DIST_PATH=/app/apps/web/dist

# ---------------------------------------------------------------------------
# Markdown rendering
# ---------------------------------------------------------------------------

# Public-facing host. Used by the server-side markdown renderer's
# external-link transform — anchors with a host different from this one
# get target="_blank" rel="noopener nofollow". See
# specs/behaviors/markdown-rendering.md. Sandbox: next-v2.codeforphilly.org.
# Production: codeforphilly.org (the default).
# CFP_SITE_HOST=codeforphilly.org
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import storePlugin from './plugins/store.js';
import reconcilePlugin from './plugins/reconcile.js';
import pushDaemonPlugin from './plugins/push-daemon.js';
import servicesPlugin from './plugins/services.js';
import markdownPlugin from './plugins/markdown.js';
import rateLimitPlugin from './plugins/rate-limit.js';
import idempotencyPlugin from './plugins/idempotency.js';
import sessionMiddlewarePlugin from './auth/middleware.js';
Expand Down Expand Up @@ -129,6 +130,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta

// ----- 6c. Services (loads in-memory state + FTS, boots after store) -----
await fastify.register(servicesPlugin);
await fastify.register(markdownPlugin);

// ----- 7. Rate limiting -----
await fastify.register(rateLimitPlugin);
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ export const EnvSchema = z.object({
* image; unset in dev (Vite owns 5173).
*/
CFP_WEB_DIST_PATH: z.string().optional(),
/**
* Host of the public-facing site (e.g. `codeforphilly.org` in prod,
* `next-v2.codeforphilly.org` in sandbox). Used by the server-side
* markdown renderer to distinguish internal from external links — anchors
* with a host different from this one get `target="_blank" rel="noopener
* nofollow"`. Per specs/behaviors/markdown-rendering.md.
*/
CFP_SITE_HOST: z.string().default('codeforphilly.org'),
});

export type Env = z.infer<typeof EnvSchema>;
Expand Down Expand Up @@ -95,5 +103,6 @@ export const envJsonSchema = {
SAML_CERTIFICATE: { type: 'string' },
SLACK_TEAM_HOST: { type: 'string', default: 'codeforphilly.slack.com' },
CFP_WEB_DIST_PATH: { type: 'string' },
CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' },
},
} as const;
44 changes: 44 additions & 0 deletions apps/api/src/plugins/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Markdown plugin.
*
* Installs a `renderMarkdown` implementation into
* `apps/api/src/services/serializers/common.ts` that closes over:
*
* - `CFP_SITE_HOST` (from env) — used by the external-link transform
* to decide which anchors get `target="_blank" rel="noopener nofollow"`.
* - `inMemoryState.personIdBySlug.has` — used by the `@mention` transform
* to resolve which usernames link to a real Person.
*
* Every serializer renders markdown via `common.renderMarkdown`, which
* dispatches to whichever function this plugin most recently installed.
* Until installed (tests, ad-hoc scripts), it falls back to the bare
* `@cfp/shared` renderer — same output as before, no transforms.
*
* Per specs/behaviors/markdown-rendering.md.
*/
import { renderMarkdown } from '@cfp/shared';
import type { FastifyInstance } from 'fastify';
import fp from 'fastify-plugin';

import { setRenderMarkdown } from '../services/serializers/common.js';

async function markdownPlugin(fastify: FastifyInstance): Promise<void> {
const siteHost = fastify.config.CFP_SITE_HOST;
// Closure over the LIVE inMemoryState reference (not its value) so the
// resolver always sees the current Map even after hot-reload swaps state
// in place. (Hot reload preserves `state` identity per
// specs/behaviors/storage.md#hot-reload — the Maps are mutated in place,
// not replaced.)
const state = fastify.inMemoryState;
setRenderMarkdown((source) =>
renderMarkdown(source, {
siteHost,
resolveMention: (slug) => state.personIdBySlug.has(slug),
}),
);
}

export default fp(markdownPlugin, {
name: 'markdown',
dependencies: ['services'],
});
2 changes: 1 addition & 1 deletion apps/api/src/routes/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* editor preview path.
*/
import type { FastifyInstance } from 'fastify';
import { renderMarkdown } from '@cfp/shared';
import { renderMarkdown } from '../services/serializers/common.js';
import { ok } from '../lib/response.js';
import { ApiValidationError } from '../lib/errors.js';

Expand Down
36 changes: 29 additions & 7 deletions apps/api/src/services/serializers/common.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
/**
* Shared serialization helpers used across entity serializers.
*/
import { renderMarkdown } from '@cfp/shared';
import { renderMarkdown as rawRenderMarkdown, type RenderMarkdownResult } from '@cfp/shared';
import type { Person, Tag } from '@cfp/shared/schemas';

/**
* Boot-installed renderer. Defaults to the bare `@cfp/shared` pipeline so
* tests + dev code that import serializers directly without booting the
* markdown plugin keep working. The markdown plugin
* (`apps/api/src/plugins/markdown.ts`) calls `setRenderMarkdown` at boot
* to swap in a renderer bound to `CFP_SITE_HOST` + the live
* `inMemoryState.personIdBySlug` lookup, so all serializer output applies
* the external-link + `@mention` transforms from
* specs/behaviors/markdown-rendering.md.
*
* Module-level state is justified here over per-call threading: every
* serializer currently routes through `renderMarkdown(source)` without
* carrying an `app` or `FastifyInstance` reference, and a per-process
* single binding matches the runtime's actual shape (one Fastify app,
* one renderer config). Hot-reload preserves the state Maps in place so
* the closure stays correct.
*/
let currentRender: (source: string) => RenderMarkdownResult = rawRenderMarkdown;

export function setRenderMarkdown(fn: (source: string) => RenderMarkdownResult): void {
currentRender = fn;
}

/** Render a markdown source through the boot-installed renderer. */
export function renderMarkdown(source: string): RenderMarkdownResult {
return currentRender(source);
}

/** PersonAvatar shape used in many nested contexts. */
export interface PersonAvatar {
readonly slug: string;
Expand Down Expand Up @@ -53,12 +81,6 @@ export function groupTagsByNamespace(
return { topic, tech, event };
}

/** Render markdown to HTML + an excerpt. Returns empty string for null/empty source. */
export function renderField(source: string | null | undefined): { html: string; excerpt: string } {
if (!source) return { html: '', excerpt: '' };
const { html, excerpt } = renderMarkdown(source);
return { html, excerpt };
}

/** Truncate a plain-text string at a word boundary. */
export function truncate(text: string, maxLength: number): string {
Expand Down
3 changes: 1 addition & 2 deletions apps/api/src/services/serializers/help-wanted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
* HelpWantedRole serializer.
*/
import type { HelpWantedRole, Person, Project, Tag, TagAssignment } from '@cfp/shared/schemas';
import { renderMarkdown } from '@cfp/shared';
import type { HelpWantedPermissions } from '../permissions.js';
import { groupTagsByNamespace, serializePersonAvatar, type TagItem } from './common.js';
import { groupTagsByNamespace, renderMarkdown, serializePersonAvatar, type TagItem } from './common.js';

export interface HelpWantedRoleResponse {
readonly id: string;
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/services/serializers/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import type {
Tag,
TagAssignment,
} from '@cfp/shared/schemas';
import { renderMarkdown } from '@cfp/shared';
import type { PersonPermissions } from '../permissions.js';
import { renderMarkdown } from './common.js';
import {
groupTagsByNamespace,
serializePersonAvatar,
Expand Down
3 changes: 1 addition & 2 deletions apps/api/src/services/serializers/project-buzz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
* ProjectBuzz serializer.
*/
import type { Person, Project, ProjectBuzz } from '@cfp/shared/schemas';
import { renderMarkdown } from '@cfp/shared';
import type { BuzzPermissions } from '../permissions.js';
import { serializePersonAvatar } from './common.js';
import { renderMarkdown, serializePersonAvatar } from './common.js';

export interface ProjectBuzzResponse {
readonly id: string;
Expand Down
3 changes: 1 addition & 2 deletions apps/api/src/services/serializers/project-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
* ProjectUpdate serializer.
*/
import type { Person, Project, ProjectUpdate } from '@cfp/shared/schemas';
import { renderMarkdown } from '@cfp/shared';
import type { UpdatePermissions } from '../permissions.js';
import { serializePersonAvatar } from './common.js';
import { renderMarkdown, serializePersonAvatar } from './common.js';

export interface ProjectUpdateResponse {
readonly id: string;
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/services/serializers/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
Tag,
TagAssignment,
} from '@cfp/shared/schemas';
import { renderMarkdown } from '@cfp/shared';
import { renderMarkdown } from './common.js';
import type { ProjectPermissions } from '../permissions.js';
import {
groupTagsByNamespace,
Expand Down
5 changes: 4 additions & 1 deletion apps/api/tests/helpers/seed-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export async function seedRawToml(
await execAsync('git', ['commit', '-m', commitMessage], { cwd: wt });
await execAsync('git', ['push', 'origin', 'main'], { cwd: wt });
} finally {
await rm(wt, { recursive: true, force: true });
// Linux ext4 + git background pack work can race the recursive rmdir
// (ENOTEMPTY on `.git/objects/`). maxRetries gives the filesystem a
// moment to settle. macOS APFS doesn't hit this; the retries are cheap.
await rm(wt, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
}

Expand Down
3 changes: 2 additions & 1 deletion apps/api/tests/helpers/test-full-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ export async function createFullDataRepo(): Promise<FullTestRepo> {

await seedGit('remote', 'add', 'origin', bareDir);
await seedGit('push', 'origin', 'main');
await rm(seedDir, { recursive: true, force: true });
// maxRetries: Linux ext4 + git background pack work races bare rmdir.
await rm(seedDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });

let cleaned = false;
return {
Expand Down
3 changes: 2 additions & 1 deletion apps/api/tests/helpers/test-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ export async function createTestRepo(
await seedGit('remote', 'add', 'origin', bareDir);
await seedGit('push', 'origin', 'main');
// Discard the transient working tree — only the bare matters from here on.
await rm(seedDir, { recursive: true, force: true });
// maxRetries: Linux ext4 + git background pack work races bare rmdir.
await rm(seedDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });

const repo = await openRepo({ gitDir: bareDir });

Expand Down
70 changes: 70 additions & 0 deletions apps/api/tests/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/app.js';
import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js';
import { seedRawToml } from './helpers/seed-fixtures.js';

let dataRepo: { path: string; cleanup: () => Promise<void> };
let privateStore: { path: string; cleanup: () => Promise<void> };
Expand Down Expand Up @@ -84,4 +85,73 @@ describe('POST /api/_preview', () => {
});
expect(res.statusCode).toBe(422);
});

it('rewrites foreign-host anchors with target=_blank rel=noopener nofollow', async () => {
const res = await app!.inject({
method: 'POST',
url: '/api/_preview',
payload: { source: '[news](https://example.com/story) and [home](/projects)' },
});
const html = res.json().data.html as string;
expect(html).toContain('target="_blank"');
expect(html).toMatch(/rel="noopener nofollow"|rel="nofollow noopener"/);
// Internal link untouched
expect(html).toContain('href="/projects">home</a>');
// Anchor count: 2; only one carries target
expect((html.match(/target="_blank"/g) ?? []).length).toBe(1);
});
});

describe('POST /api/_preview — @mention resolution', () => {
beforeEach(async () => {
// The test rig seeded above clears between cases; reseed a Person so the
// resolver has something to find. Skip if buildApp pulled state already —
// the helper writes a TOML, then we need a fresh app to see it.
await seedRawToml(
dataRepo.path,
'people/chris.toml',
[
'id = "019e4021-0000-7000-8000-000000000001"',
'slug = "chris"',
'fullName = "Test chris"',
'accountLevel = "user"',
'createdAt = "2026-05-01T00:00:00Z"',
'updatedAt = "2026-05-01T00:00:00Z"',
].join('\n'),
'seed person chris',
);
// Re-boot the app so it loads the new state.
await app!.close();
app = await buildApp({
serverOptions: { logger: false },
overrideEnv: {
CFP_DATA_REPO_PATH: dataRepo.path,
STORAGE_BACKEND: 'filesystem',
CFP_PRIVATE_STORAGE_PATH: privateStore.path,
CFP_JWT_SIGNING_KEY: 'test-jwt-signing-key-at-least-32-chars!!',
NODE_ENV: 'test',
},
});
});

it('linkifies @<slug> when the slug resolves to a Person', async () => {
const res = await app!.inject({
method: 'POST',
url: '/api/_preview',
payload: { source: 'hi @chris, welcome' },
});
const html = res.json().data.html as string;
expect(html).toContain('<a href="/members/chris">@chris</a>');
});

it('leaves unknown @mentions as literal text', async () => {
const res = await app!.inject({
method: 'POST',
url: '/api/_preview',
payload: { source: 'cc @nobody for context' },
});
const html = res.json().data.html as string;
expect(html).not.toContain('href="/members/nobody"');
expect(html).toContain('@nobody');
});
});
4 changes: 4 additions & 0 deletions deploy/kustomize/base/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ data:
CFP_DATA_REPO_PATH: "/app/data"
CFP_PRIVATE_STORAGE_PATH: "/app/private-storage"
CFP_WEB_DIST_PATH: "/app/apps/web/dist"
# Public-facing host. Used by the server-side markdown renderer to flag
# anchors with a foreign host for target=_blank rel=noopener nofollow.
# Sandbox overlay overrides this to next-v2.codeforphilly.org.
CFP_SITE_HOST: "codeforphilly.org"
GIT_AUTHOR_EMAIL: "api@codeforphilly.org"
GIT_AUTHOR_NAME: "CodeForPhilly API"
NODE_ENV: "production"
Expand Down
11 changes: 11 additions & 0 deletions deploy/kustomize/overlays/sandbox/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,14 @@ patches:
- op: replace
path: /spec/hostnames/0
value: next-v2.codeforphilly.org
# Sandbox lives at next-v2.codeforphilly.org, not codeforphilly.org —
# tell the markdown renderer's external-link transform to treat that
# as the "internal" host so internal-link rendering matches expectations.
- target:
version: v1
kind: ConfigMap
name: codeforphilly-env
patch: |
- op: replace
path: /data/CFP_SITE_HOST
value: next-v2.codeforphilly.org
1 change: 1 addition & 0 deletions docs/operations/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ comments. Production pod gets these mounted:
| `CFP_DATA_BRANCH` | ConfigMap | e.g. `fixture` / `main` |
| `CFP_DATA_RELOAD_SECRET` | **Secret** | Shared bearer-token for the hot-reload webhook; when unset the `/api/_internal/reload-data` endpoint returns 503. See [runbook.md](runbook.md#hot-reload-webhook). |
| `CFP_WEB_DIST_PATH` | ConfigMap | `/app/apps/web/dist` |
| `CFP_SITE_HOST` | ConfigMap | Public-facing host (`codeforphilly.org` base, `next-v2.codeforphilly.org` sandbox). Drives the markdown renderer's external-link transform — anchors with a different host get `target="_blank" rel="noopener nofollow"`. |
| `STORAGE_BACKEND` | ConfigMap | `s3` (prod) / `filesystem` (sandbox) |
| `CFP_PRIVATE_STORAGE_PATH` | ConfigMap | `/app/private-storage` (when filesystem) |
| `S3_ENDPOINT` / `S3_BUCKET` / `S3_REGION` | ConfigMap | Bucket addressing |
Expand Down
Loading