Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
f0b0240
docs(plans): plan gitsheets 1.4.1 → 2.x bump (#150)
themightychris Jul 4, 2026
2b3bff7
chore(deps): bump gitsheets to 2.x (Rust core)
themightychris Jul 4, 2026
a458f86
feat(api): migrate blob-write path from hologit to gitsheets 2.x API
themightychris Jul 4, 2026
2838aa7
fix(store): strip null/undefined keys before gitsheets 2.x marshal
themightychris Jul 4, 2026
841bd4a
docs(plans): note gitsheets 2.x null-marshal break + check off valida…
themightychris Jul 4, 2026
5e5e219
test(store): fix null-marshal assertion false-matching fixture name
themightychris Jul 4, 2026
fec376e
Merge pull request #151 from CodeForPhilly/chore/gitsheets-2x-bump
themightychris Jul 4, 2026
89f21e8
build(api): swap resend for postmark
themightychris Sep 8, 2026
1c609aa
docs(specs): move the email transport from Resend to Postmark
themightychris Sep 8, 2026
6b8d01f
feat(api): send notifier email through Postmark
themightychris Sep 8, 2026
6a569b6
chore(plans): mark postmark-notifier done (PR #158)
themightychris Sep 8, 2026
a1e83cf
Merge pull request #158 from CodeForPhilly/feat/postmark-notifier
themightychris Sep 8, 2026
5dbde9e
docs(specs): hot reload must rebuild every in-memory index
themightychris Sep 8, 2026
3e3c48a
chore(plans): add hot-reload-stale-indices plan
themightychris Sep 8, 2026
55c720f
fix(api): rebuild every in-memory index on hot reload
themightychris Sep 8, 2026
d7c5f9f
chore(plans): mark hot-reload-stale-indices done (PR #159)
themightychris Sep 8, 2026
8357170
Merge pull request #159 from CodeForPhilly/fix/hot-reload-stale-indices
themightychris Sep 9, 2026
86b988c
docs(saml): spec entityID as SAML_ENTITY_ID, endpoints on CFP_SITE_HOST
themightychris Sep 8, 2026
4536eaa
fix(api): build SAML entityID and SSO endpoints from our own settings
themightychris Sep 9, 2026
b8a3fe1
chore(plans): mark saml-self-host done (PR #161)
themightychris Sep 9, 2026
2d4fabd
Merge pull request #161 from CodeForPhilly/fix/saml-self-host
themightychris Sep 9, 2026
dd34602
chore(plans): add site-check-153 plan
heyoub Aug 24, 2026
916ef65
docs(specs): reorder header clusters, repoint dead GitBook links
heyoub Aug 24, 2026
5623b5a
fix(web): move Volunteer CTA rightmost, pad the mobile sheet
heyoub Aug 24, 2026
a768f87
fix(web): repoint dead GitBook links and the stale footer repo URL
heyoub Aug 24, 2026
3ca3d95
fix(web): let the mobile sheet nav scroll when it overflows
heyoub Aug 24, 2026
54f9745
chore(plans): record site-check-153 validation results
heyoub Aug 24, 2026
5729702
chore(plans): record exact api test counts in site-check-153 notes
heyoub Aug 24, 2026
bb05e0f
chore(plans): record browser validation for site-check-153
heyoub Aug 24, 2026
e092de3
chore(plans): mark site-check-153 done (PR #154)
heyoub Aug 24, 2026
eb1cbc3
docs: repoint stale codeforphilly-rewrite GitHub URLs
themightychris Sep 9, 2026
12dea00
chore(web): remove dead HomeStub page
themightychris Sep 9, 2026
9201cbe
test(web): settle AuthProvider before Volunteer tests return
themightychris Sep 9, 2026
bebd034
refactor(web): lift GitHubIcon into a shared component
themightychris Sep 9, 2026
ffdc5e9
fix(web): close the mobile sheet on any navigation
themightychris Sep 9, 2026
6cdccd1
fix(web): describe the sheet dialog, drop its dead width class
themightychris Sep 9, 2026
b417c87
fix(web): wrap the utility cluster in a navigation landmark
themightychris Sep 9, 2026
d5a4221
fix(web): keep the header on one line between md and lg
themightychris Sep 9, 2026
684ca34
chore(plans): record review closeout for site-check-153
themightychris Sep 9, 2026
a24e5d2
Merge pull request #154 from heyoub/fix/site-check-153
themightychris Sep 9, 2026
1bb71db
chore(plans): add aria-correctness plan
heyoub Aug 24, 2026
b7ebc7b
fix(web): rebuild SearchBox as an APG combobox
heyoub Aug 24, 2026
833e62f
fix(web): rebuild TagPicker as an APG combobox
heyoub Aug 24, 2026
b60a1f4
fix(web): repair invalid and redundant ARIA across components
heyoub Aug 24, 2026
173bd78
fix(web): associate form error text with the fields it describes
heyoub Aug 24, 2026
e39aac0
chore(plans): record the "Retry" banner spec-code contradiction
heyoub Aug 24, 2026
2722874
fix(web): keep StageProgressBar to a single tooltip tab stop
heyoub Aug 24, 2026
69ac5aa
chore(plans): mark aria-correctness done (PR #155)
heyoub Aug 24, 2026
a27b3d5
docs(specs): describe combobox keys and the error banner's Retry
themightychris Sep 9, 2026
6ad7643
fix(web): tidy SearchBox activation, hover and inline layout
themightychris Sep 9, 2026
169698b
fix(web): close TagPicker on focus-out and reopen it on click
themightychris Sep 9, 2026
d6cb37c
fix(web): trim over-eager ARIA on TagChip, StageBadge, PersonAvatar
themightychris Sep 9, 2026
654eb42
fix(web): make the error banner's Retry actually retry
themightychris Sep 9, 2026
17223e0
test(web): raise vitest testTimeout to 15s
themightychris Sep 9, 2026
908d4e1
chore(plans): record review closeout for aria-correctness
themightychris Sep 9, 2026
e061318
Merge pull request #155 from heyoub/fix/aria-correctness
themightychris Sep 9, 2026
5fc51f7
chore(plans): add a11y-mechanical plan
heyoub Aug 24, 2026
0179758
fix(web): render the breadcrumb trails app-shell.md prescribes
heyoub Aug 24, 2026
42cffe6
fix(web): repair heading levels and landmark structure
heyoub Aug 24, 2026
7750024
fix(web): give the toolbar semantics and announce silent state changes
heyoub Aug 24, 2026
ede6bf4
fix(web): name per-row actions, mark up dates, cue new tabs
heyoub Aug 24, 2026
de6b1e8
test(web): cover breadcrumbs, per-row names and time elements
heyoub Aug 24, 2026
c50446d
chore(plans): record a11y-mechanical validation results
heyoub Aug 24, 2026
abfd1dd
chore(plans): mark a11y-mechanical done (PR #157)
heyoub Aug 24, 2026
d32185f
fix(web): restore full-width sheet rows, keep Commitment in the landmark
themightychris Sep 9, 2026
f419c26
fix(web): keep the Connect GitHub banner a region, announce it beside
themightychris Sep 9, 2026
0628779
fix(web): key crumbs by position, label the namespace crumb, guard edit
themightychris Sep 9, 2026
ab461e0
fix(web): guard clipboard, size the status span, finish <time> sweep
themightychris Sep 9, 2026
dc9055b
refactor(web): derive the toolbar's roving index from the event target
themightychris Sep 9, 2026
e8e9bc6
chore(plans): record review closeout for a11y-mechanical
themightychris Sep 9, 2026
96b71ef
Merge pull request #157 from heyoub/fix/a11y-mechanical
themightychris Sep 9, 2026
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
27 changes: 21 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars
# PEM-encoded certificate matching SAML_PRIVATE_KEY.
# SAML_CERTIFICATE=-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----

# Stable IdP entity ID — also the <Issuer> on every assertion. Slack stores
# this at setup, so it must NOT change when CFP_SITE_HOST flips at cutover.
# Leave unset unless registering a separate IdP with a different workspace.
# See specs/api/saml.md#idp-identity-and-hosts.
# SAML_ENTITY_ID=https://codeforphilly.org/api/saml/slack/metadata

# Slack workspace host. Drives the ACS URL, NameID NameQualifier, and the
# /chat + /launch redirects. Never used for our own entity ID or endpoints.
# SLACK_TEAM_HOST=codeforphilly.slack.com

# ---------------------------------------------------------------------------
# Static SPA serving (production only)
# ---------------------------------------------------------------------------
Expand All @@ -92,14 +102,19 @@ CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars
# CFP_SITE_HOST=codeforphilly.org

# ---------------------------------------------------------------------------
# Outbound notifications (Resend)
# Outbound notifications (Postmark)
# ---------------------------------------------------------------------------

# Resend API key for the help-wanted email notifier. When unset, the
# notifier falls back to a no-op LoggingNotifier so dev + tests work
# without an account. See plans/notifier-email.md.
# RESEND_API_KEY=re_…
# Postmark server token for the email notifier (help-wanted, welcome,
# password-reset). When unset, the notifier falls back to a no-op
# LoggingNotifier so dev + tests work without an account. See
# plans/postmark-notifier.md and docs/operations/secrets.md.
# POSTMARK_SERVER_TOKEN=…

# Postmark message stream to send on. Defaults to `outbound`, the
# transactional stream every Postmark server ships with.
# POSTMARK_MESSAGE_STREAM=outbound

# From-address for outbound notifications. RFC 5322 form.
# Only used when RESEND_API_KEY is set.
# Only used when POSTMARK_SERVER_TOKEN is set.
# CFP_NOTIFICATION_FROM="Code for Philly <notifications@codeforphilly.org>"
4 changes: 2 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"fastify": "^5.8.5",
"gitsheets": "^1.4.1",
"gitsheets": "^2.2.0",
"jose": "^6.2.3",
"resend": "^6.12.4",
"postmark": "^5.1.0",
"samlify": "^2.13.0",
"sharp": "^0.34.5",
"uuidv7": "^1.2.1",
Expand Down
50 changes: 20 additions & 30 deletions apps/api/scripts/cutover-mailout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,26 @@
* them to sign in and claim their account. Run manually at T+90 per
* specs/behaviors/account-migration.md#cutover-window-policy.
*
* --dry-run prints the would-be send list and exits — no Resend calls, no
* --dry-run prints the would-be send list and exits — no Postmark calls, no
* disk writes. The CI test exercises only --dry-run.
*
* Usage:
* npm run -w apps/api script:cutover-mailout -- --dry-run
* npm run -w apps/api script:cutover-mailout -- --send --from=hello@codeforphilly.org
*
* Env:
* RESEND_API_KEY — required for actual sends (otherwise --send refuses)
* POSTMARK_SERVER_TOKEN — required for actual sends (otherwise --send refuses)
* POSTMARK_MESSAGE_STREAM — optional; defaults to `outbound`
* CFP_PUBLIC_URL — base URL used in the email body (defaults to
* https://codeforphilly.org)
* CFP_DATA_REPO_PATH + STORAGE_BACKEND + bucket envs — same shape as the API
*/
import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';

import { ServerClient } from 'postmark';

import { PostmarkTransport } from '../src/notify/postmark-transport.js';
import { openPublicStore, type PublicStore } from '../src/store/public.js';
import {
FilesystemPrivateStore,
Expand Down Expand Up @@ -195,7 +199,7 @@ export async function runMailout(opts: MailoutOptions): Promise<MailoutReport> {
}

// ---------------------------------------------------------------------------
// Env wiring + Resend send
// Env wiring + Postmark send
// ---------------------------------------------------------------------------

function requireEnv(name: string): string {
Expand All @@ -220,33 +224,19 @@ function buildPrivateStore(): PrivateStore {
});
}

/** Resend HTTP send. Fetch-based to avoid adding a new dep at this stage. */
async function resendSend(input: {
to: string;
from: string;
subject: string;
html: string;
text: string;
}): Promise<void> {
const apiKey = requireEnv('RESEND_API_KEY');
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'authorization': `Bearer ${apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify({
from: input.from,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
}),
/**
* Postmark send via the same transport the API's notifier uses. The SDK
* throws on any non-2xx, which runMailout() records per-recipient in
* `failed` rather than aborting the run.
*/
function buildPostmarkSend(): NonNullable<MailoutOptions['send']> {
const transport = new PostmarkTransport({
client: new ServerClient(requireEnv('POSTMARK_SERVER_TOKEN')),
messageStream: process.env['POSTMARK_MESSAGE_STREAM'] || undefined,
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Resend ${res.status}: ${body.slice(0, 200)}`);
}
return async (input) => {
await transport.send(input);
};
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -299,7 +289,7 @@ async function main(): Promise<void> {
mode: args.dryRun ? 'dry-run' : 'send',
from: args.from,
publicUrl: args.publicUrl ?? process.env['CFP_PUBLIC_URL'],
send: args.send ? resendSend : undefined,
send: args.send ? buildPostmarkSend() : undefined,
});

process.stderr.write(
Expand Down
21 changes: 7 additions & 14 deletions apps/api/scripts/import-laddr/importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ import {
type TranslateCtx,
type Warnings,
} from './translators.js';
import { BlobObject } from 'hologit';
import type { BlobHandle } from 'gitsheets';

// ---------------------------------------------------------------------------
// Public types
Expand Down Expand Up @@ -187,7 +187,7 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
// -------------------------------------------------------------------------
let store: PublicStore | null = null;
// Gitsheets Repository — needed to write attachment blobs via
// BlobObject.write into the underlying git object DB.
// repo.writeBlob into the underlying git object DB.
let publicRepo: Awaited<ReturnType<typeof openPublicStore>>['repo'] | null = null;
let existingIds: ExistingIds;

Expand Down Expand Up @@ -500,7 +500,6 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
if (publicRepo === null) {
throw new Error('[import-laddr] internal: publicRepo not opened');
}
const hologit = publicRepo.hologitRepo;

log(`[import] clear + upsert tags (${tags.length})`);
await tx.tags.clear();
Expand All @@ -513,8 +512,8 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
if (avatar) {
// Mirror POST /api/people/:slug/avatar: store original + 128 thumb
// as attachments and point avatarKey at the conventional path.
const originalBlob = await BlobObject.write(hologit, avatar.original as unknown as string);
const thumbnailBlob = await BlobObject.write(hologit, avatar.thumbnail as unknown as string);
const originalBlob = await publicRepo.writeBlob(avatar.original);
const thumbnailBlob = await publicRepo.writeBlob(avatar.thumbnail);
await tx.people.setAttachments(p, {
'avatar.jpg': originalBlob,
'avatar-128.jpg': thumbnailBlob,
Expand Down Expand Up @@ -580,16 +579,10 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
for (const { record } of blogTranslations) {
const artifacts = mediaArtifactsBySlug.get(record.slug) ?? [];
if (artifacts.length > 0) {
const blobs: Record<string, BlobObject> = {};
const blobs: Record<string, BlobHandle> = {};
for (const a of artifacts) {
// BlobObject.write hashes the buffer into the git object DB.
// Same `as unknown as string` cast as the avatar route — the
// declared signature is too narrow; the underlying
// git-client `$putBlob` accepts Buffer at runtime.
blobs[a.filename] = await BlobObject.write(
hologit,
a.bytes as unknown as string,
);
// repo.writeBlob hashes the Buffer into the git object DB.
blobs[a.filename] = await publicRepo.writeBlob(a.bytes);
}
await tx['blog-posts'].setAttachments(record, blobs);
}
Expand Down
43 changes: 34 additions & 9 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
*/
import { z } from 'zod';

/**
* Default SAML IdP entity ID. Stable across hosts — see the SAML_ENTITY_ID
* field below and specs/api/saml.md#idp-identity-and-hosts.
*/
export const SAML_ENTITY_ID_DEFAULT = 'https://codeforphilly.org/api/saml/slack/metadata';

export const EnvSchema = z.object({
/** TCP port the Fastify server listens on. */
PORT: z.coerce.number().default(3001),
Expand Down Expand Up @@ -49,8 +55,17 @@ export const EnvSchema = z.object({
/** SAML IdP certificate (PEM) for the Slack SAML integration. */
SAML_CERTIFICATE: z.string().optional(),
/**
* Slack workspace host. Used as the SAML `NameQualifier` per
* specs/api/saml.md and shared with the `/chat` redirect handler.
* SAML IdP entity ID — the metadata `entityID` and the `<Issuer>` on every
* assertion. A stable logical identifier Slack stores at setup time, so it
* deliberately does NOT follow CFP_SITE_HOST: the pre-cutover
* `next.codeforphilly.org` deploy and the post-cutover `codeforphilly.org`
* deploy present the same issuer. Per specs/api/saml.md#idp-identity-and-hosts.
*/
SAML_ENTITY_ID: z.url().default(SAML_ENTITY_ID_DEFAULT),
/**
* Slack workspace host. Used for the SAML ACS URL and `NameQualifier` per
* specs/api/saml.md and shared with the `/chat` redirect handler. Never
* used for our own IdP entity ID or endpoint URLs.
*/
SLACK_TEAM_HOST: z.string().default('codeforphilly.slack.com'),
/**
Expand All @@ -64,19 +79,27 @@ export const EnvSchema = z.object({
* `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.
* nofollow"`. Per specs/behaviors/markdown-rendering.md. Also the host the
* SAML IdP metadata advertises for its SSO endpoint Locations (per
* specs/api/saml.md#idp-identity-and-hosts).
*/
CFP_SITE_HOST: z.string().default('codeforphilly.org'),
/**
* Resend API key for the email notifier. When unset, the services plugin
* falls back to LoggingNotifier so dev + test runs don't need a real key.
* See plans/notifier-email.md.
* Postmark server token for the email notifier. When unset, the services
* plugin falls back to LoggingNotifier so dev + test runs don't need a
* real token. See plans/postmark-notifier.md.
*/
POSTMARK_SERVER_TOKEN: z.string().optional(),
/**
* Postmark message stream outbound mail is sent on. `outbound` is the
* transactional default stream every Postmark server ships with. Only
* relevant when POSTMARK_SERVER_TOKEN is set.
*/
RESEND_API_KEY: z.string().optional(),
POSTMARK_MESSAGE_STREAM: z.string().default('outbound'),
/**
* From-address for outbound notifications. RFC 5322 form
* (e.g. `"Code for Philly <notifications@codeforphilly.org>"`). Only
* relevant when RESEND_API_KEY is set.
* relevant when POSTMARK_SERVER_TOKEN is set.
*/
CFP_NOTIFICATION_FROM: z
.string()
Expand Down Expand Up @@ -115,10 +138,12 @@ export const envJsonSchema = {
CFP_JWT_SIGNING_KEY: { type: 'string', minLength: 1 },
SAML_PRIVATE_KEY: { type: 'string' },
SAML_CERTIFICATE: { type: 'string' },
SAML_ENTITY_ID: { type: 'string', default: SAML_ENTITY_ID_DEFAULT },
SLACK_TEAM_HOST: { type: 'string', default: 'codeforphilly.slack.com' },
CFP_WEB_DIST_PATH: { type: 'string' },
CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' },
RESEND_API_KEY: { type: 'string' },
POSTMARK_SERVER_TOKEN: { type: 'string' },
POSTMARK_MESSAGE_STREAM: { type: 'string', default: 'outbound' },
CFP_NOTIFICATION_FROM: {
type: 'string',
default: 'Code for Philly <notifications@codeforphilly.org>',
Expand Down
Loading
Loading