Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,6 @@
"personId",
"createdAt"
],
"additionalProperties": false,
"additionalProperties": {},
"title": "HelpWantedInterestExpression"
}
2 changes: 1 addition & 1 deletion .gitsheets/schemas/HelpWantedRole.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,6 @@
"createdAt",
"updatedAt"
],
"additionalProperties": false,
"additionalProperties": {},
"title": "HelpWantedRole"
}
2 changes: 1 addition & 1 deletion .gitsheets/schemas/ProjectBuzz.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,6 @@
"createdAt",
"updatedAt"
],
"additionalProperties": false,
"additionalProperties": {},
"title": "ProjectBuzz"
}
2 changes: 1 addition & 1 deletion .gitsheets/schemas/ProjectMembership.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,6 @@
"createdAt",
"updatedAt"
],
"additionalProperties": false,
"additionalProperties": {},
"title": "ProjectMembership"
}
2 changes: 1 addition & 1 deletion .gitsheets/schemas/ProjectUpdate.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,6 @@
"createdAt",
"updatedAt"
],
"additionalProperties": false,
"additionalProperties": {},
"title": "ProjectUpdate"
}
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"test": "vitest run",
"script:scrub-data": "tsx scripts/scrub-data.ts",
"script:setup-dev-data": "tsx scripts/setup-dev-data.ts",
"script:import-laddr": "tsx scripts/import-laddr.ts"
"script:import-laddr": "tsx scripts/import-laddr.ts",
"script:reconcile-private-store": "tsx scripts/reconcile-private-store.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1048.0",
Expand Down
147 changes: 147 additions & 0 deletions apps/api/scripts/reconcile-private-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Reconcile the private store against the public people sheet.
//
// Walks every Person in the public gitsheets repo and confirms each one has
// a corresponding `PrivateProfile` entry in the bucket. Flags orphans on
// both sides:
//
// - public Person with no matching private profile
// - private profile referencing a personId that does not exist publicly
//
// Optionally repairs missing private profiles with a `--fix` flag — creates
// a placeholder profile with `email: <slug>@example.invalid` so the API
// boot can find a row to read. Use `--fix` only in dev / disaster-recovery
// — production should investigate the underlying split before bulk-fixing.
//
// Usage:
// npm run -w apps/api script:reconcile-private-store # report only
// npm run -w apps/api script:reconcile-private-store -- --fix # report + repair missing profiles
//
// Reads CFP_DATA_REPO_PATH + STORAGE_BACKEND + CFP_PRIVATE_STORAGE_PATH (or
// the S3 vars) from the env, same as the API.
import 'dotenv/config';
import { PrivateProfileSchema, type PrivateProfile } from '@cfp/shared/schemas';
import { openPublicStore } from '../src/store/public.js';
import { FilesystemPrivateStore } from '../src/store/private/filesystem.js';
import { S3PrivateStore } from '../src/store/private/s3.js';
import type { PrivateStore } from '../src/store/private/index.js';

interface ReconcileReport {
readonly publicCount: number;
readonly privateCount: number;
readonly missingPrivateForPublic: ReadonlyArray<{ personId: string; slug: string }>;
readonly orphanedPrivate: ReadonlyArray<{ personId: string }>;
}

function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required env var ${name}`);
return v;
}

function buildPrivateStore(): PrivateStore {
const backend = requireEnv('STORAGE_BACKEND');
if (backend === 's3') {
return new S3PrivateStore({
S3_ENDPOINT: requireEnv('S3_ENDPOINT'),
S3_BUCKET: requireEnv('S3_BUCKET'),
S3_ACCESS_KEY_ID: requireEnv('S3_ACCESS_KEY_ID'),
S3_SECRET_ACCESS_KEY: requireEnv('S3_SECRET_ACCESS_KEY'),
S3_REGION: requireEnv('S3_REGION'),
});
}
return new FilesystemPrivateStore({
CFP_PRIVATE_STORAGE_PATH: requireEnv('CFP_PRIVATE_STORAGE_PATH'),
});
}

async function reconcile(): Promise<ReconcileReport> {
const repoPath = requireEnv('CFP_DATA_REPO_PATH');
const publicStore = await openPublicStore(repoPath);
const privateStore = buildPrivateStore();
await privateStore.load();

const people = await publicStore.people.queryAll();
const publicIds = new Set(people.map((p) => p.id));

const missingPrivateForPublic: Array<{ personId: string; slug: string }> = [];
for (const person of people) {
if (person.deletedAt) continue;
const profile = await privateStore.getProfile(person.id);
if (!profile) {
missingPrivateForPublic.push({ personId: person.id, slug: person.slug });
}
}

const orphanedPrivate: Array<{ personId: string }> = [];
let privateCount = 0;
for await (const profile of privateStore.listAllProfiles()) {
privateCount++;
if (!publicIds.has(profile.personId)) {
orphanedPrivate.push({ personId: profile.personId });
}
}

return {
publicCount: people.length,
privateCount,
missingPrivateForPublic,
orphanedPrivate,
};
}

async function fixMissing(): Promise<number> {
const repoPath = requireEnv('CFP_DATA_REPO_PATH');
const publicStore = await openPublicStore(repoPath);
const privateStore = buildPrivateStore();
await privateStore.load();

const people = await publicStore.people.queryAll();
let fixed = 0;
for (const person of people) {
if (person.deletedAt) continue;
const existing = await privateStore.getProfile(person.id);
if (existing) continue;
const now = new Date().toISOString();
const profile: PrivateProfile = PrivateProfileSchema.parse({
personId: person.id,
email: `${person.slug}@example.invalid`,
emailRefreshedAt: now,
newsletter: null,
updatedAt: now,
});
await privateStore.putProfile(profile);
fixed++;
}
return fixed;
}

async function main(): Promise<void> {
const argv = process.argv.slice(2);
const wantFix = argv.includes('--fix');

const report = await reconcile();

process.stdout.write(`Public people: ${report.publicCount}\n`);
process.stdout.write(`Private profiles: ${report.privateCount}\n`);
process.stdout.write(
`Missing private for public: ${report.missingPrivateForPublic.length}\n`,
);
for (const m of report.missingPrivateForPublic) {
process.stdout.write(` - ${m.slug} (${m.personId})\n`);
}
process.stdout.write(`Orphaned private profiles: ${report.orphanedPrivate.length}\n`);
for (const o of report.orphanedPrivate) {
process.stdout.write(` - ${o.personId}\n`);
}

if (wantFix && report.missingPrivateForPublic.length > 0) {
process.stdout.write(`\nApplying --fix...\n`);
const fixed = await fixMissing();
process.stdout.write(`Fixed ${fixed} missing profiles\n`);
}
}

main().catch((err) => {
process.stderr.write(`reconcile-private-store failed: ${String(err)}\n`);
process.exitCode = 1;
});
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { tagRoutes } from './routes/tags.js';
import { projectUpdateRoutes } from './routes/projects-updates.js';
import { projectBuzzRoutes } from './routes/projects-buzz.js';
import { helpWantedRoutes } from './routes/projects-help-wanted.js';
import { projectMembershipRoutes } from './routes/projects-members.js';

declare module 'fastify' {
interface FastifyInstance {
Expand Down Expand Up @@ -144,6 +145,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
await fastify.register(projectUpdateRoutes);
await fastify.register(projectBuzzRoutes);
await fastify.register(helpWantedRoutes);
await fastify.register(projectMembershipRoutes);

// Serve the OpenAPI JSON at the spec-mandated path /api/_openapi.json
// (swagger-ui also exposes it at /api/_docs/json, but the spec names this path)
Expand Down
132 changes: 132 additions & 0 deletions apps/api/src/auth/require.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* requireAuth — typed entry point for marker-based authorization.
*
* Wraps the simpler route-level guard from ./guards.ts with marker
* vocabulary from specs/behaviors/authorization.md. Used at the service
* boundary for defense-in-depth: routes call requireAuth(request, markers)
* first; services then call requireAuthMarker(session, marker, ctx) again
* with full entity context (project, owned-resource, self) to decide
* `self`, `member`, `maintainer`, `poster`/`author` cases.
*/
import type { Project, ProjectMembership } from '@cfp/shared/schemas';
import { ForbiddenError, UnauthenticatedError } from '../lib/errors.js';
import type { SessionContext } from './middleware.js';

/** A marker expression: `user`, `maintainer | staff`, `self | staff`, etc. */
export type MarkerExpression = string;

export interface AuthContext {
/** Caller's session. */
readonly session: SessionContext;
/** When `self` is in the expression — the resource owner's personId or slug. */
readonly selfId?: string;
readonly selfSlug?: string;
/** When `maintainer` / `member` are in the expression — the project + its memberships. */
readonly project?: Project;
readonly memberships?: readonly ProjectMembership[];
/** When `poster`/`author` is in the expression — the resource owner's personId. */
readonly ownerId?: string;
}

function isStaff(session: SessionContext): boolean {
return session.accountLevel === 'staff' || session.accountLevel === 'administrator';
}

function isAdministrator(session: SessionContext): boolean {
return session.accountLevel === 'administrator';
}

function isAuthenticated(session: SessionContext): boolean {
return session.accountLevel !== 'anonymous' && session.person !== null;
}

function isSelf(session: SessionContext, ctx: AuthContext): boolean {
if (!session.person) return false;
if (ctx.selfId !== undefined) return session.person.id === ctx.selfId;
if (ctx.selfSlug !== undefined) return session.person.slug === ctx.selfSlug;
return false;
}

function isMaintainer(session: SessionContext, ctx: AuthContext): boolean {
if (!session.person || !ctx.project) return false;
if (ctx.project.maintainerId === session.person.id) return true;
return (ctx.memberships ?? []).some(
(m) => m.personId === session.person!.id && m.isMaintainer,
);
}

function isMember(session: SessionContext, ctx: AuthContext): boolean {
if (!session.person || !ctx.project) return false;
return (ctx.memberships ?? []).some(
(m) => m.personId === session.person!.id && m.projectId === ctx.project!.id,
);
}

function isOwner(session: SessionContext, ctx: AuthContext): boolean {
if (!session.person || ctx.ownerId === undefined) return false;
return session.person.id === ctx.ownerId;
}

const MARKER_TOKENS = new Set([
'public',
'user',
'self',
'staff',
'administrator',
'member',
'maintainer',
'poster',
'author',
]);

/**
* Check a marker expression like `maintainer | staff` against the session.
*
* Throws `UnauthenticatedError` if any non-`public` marker is required and the
* caller is anonymous; throws `ForbiddenError` if the caller is authenticated
* but none of the markers match.
*/
export function requireAuth(expression: MarkerExpression, ctx: AuthContext): SessionContext {
const tokens = expression
.split('|')
.map((t) => t.trim())
.filter(Boolean);

if (tokens.length === 0) {
throw new Error(`requireAuth: empty marker expression`);
}
for (const t of tokens) {
if (!MARKER_TOKENS.has(t)) {
throw new Error(`requireAuth: unknown marker '${t}' in '${expression}'`);
}
}

const { session } = ctx;

if (tokens.includes('public')) return session;

// Every remaining marker requires authentication.
if (!isAuthenticated(session)) {
throw new UnauthenticatedError('Authentication required');
}

for (const token of tokens) {
if (token === 'user' && isAuthenticated(session)) return session;
if (token === 'staff' && isStaff(session)) return session;
if (token === 'administrator' && isAdministrator(session)) return session;
if (token === 'self' && isSelf(session, ctx)) return session;
if (token === 'maintainer' && isMaintainer(session, ctx)) return session;
if (token === 'member' && isMember(session, ctx)) return session;
if ((token === 'poster' || token === 'author') && isOwner(session, ctx)) return session;
}

throw new ForbiddenError('Insufficient permissions');
}

/** Convenience: throws `UnauthenticatedError` unless the caller is signed in. */
export function requireSignedIn(session: SessionContext): SessionContext {
if (!isAuthenticated(session)) {
throw new UnauthenticatedError('Authentication required');
}
return session;
}
Loading