Skip to content

Commit e19ee0c

Browse files
Merge pull request #152 from CodeForPhilly/develop
Release: v0.2.0
2 parents 0d59c09 + 96b71ef commit e19ee0c

102 files changed

Lines changed: 4521 additions & 1930 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars
7171
# PEM-encoded certificate matching SAML_PRIVATE_KEY.
7272
# SAML_CERTIFICATE=-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----
7373

74+
# Stable IdP entity ID — also the <Issuer> on every assertion. Slack stores
75+
# this at setup, so it must NOT change when CFP_SITE_HOST flips at cutover.
76+
# Leave unset unless registering a separate IdP with a different workspace.
77+
# See specs/api/saml.md#idp-identity-and-hosts.
78+
# SAML_ENTITY_ID=https://codeforphilly.org/api/saml/slack/metadata
79+
80+
# Slack workspace host. Drives the ACS URL, NameID NameQualifier, and the
81+
# /chat + /launch redirects. Never used for our own entity ID or endpoints.
82+
# SLACK_TEAM_HOST=codeforphilly.slack.com
83+
7484
# ---------------------------------------------------------------------------
7585
# Static SPA serving (production only)
7686
# ---------------------------------------------------------------------------
@@ -92,14 +102,19 @@ CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars
92102
# CFP_SITE_HOST=codeforphilly.org
93103

94104
# ---------------------------------------------------------------------------
95-
# Outbound notifications (Resend)
105+
# Outbound notifications (Postmark)
96106
# ---------------------------------------------------------------------------
97107

98-
# Resend API key for the help-wanted email notifier. When unset, the
99-
# notifier falls back to a no-op LoggingNotifier so dev + tests work
100-
# without an account. See plans/notifier-email.md.
101-
# RESEND_API_KEY=re_…
108+
# Postmark server token for the email notifier (help-wanted, welcome,
109+
# password-reset). When unset, the notifier falls back to a no-op
110+
# LoggingNotifier so dev + tests work without an account. See
111+
# plans/postmark-notifier.md and docs/operations/secrets.md.
112+
# POSTMARK_SERVER_TOKEN=…
113+
114+
# Postmark message stream to send on. Defaults to `outbound`, the
115+
# transactional stream every Postmark server ships with.
116+
# POSTMARK_MESSAGE_STREAM=outbound
102117

103118
# From-address for outbound notifications. RFC 5322 form.
104-
# Only used when RESEND_API_KEY is set.
119+
# Only used when POSTMARK_SERVER_TOKEN is set.
105120
# CFP_NOTIFICATION_FROM="Code for Philly <notifications@codeforphilly.org>"

‎apps/api/package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@
3535
"bcryptjs": "^3.0.3",
3636
"better-sqlite3": "^12.10.0",
3737
"fastify": "^5.8.5",
38-
"gitsheets": "^1.4.1",
38+
"gitsheets": "^2.2.0",
3939
"jose": "^6.2.3",
40-
"resend": "^6.12.4",
40+
"postmark": "^5.1.0",
4141
"samlify": "^2.13.0",
4242
"sharp": "^0.34.5",
4343
"uuidv7": "^1.2.1",

‎apps/api/scripts/cutover-mailout.ts‎

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,26 @@
66
* them to sign in and claim their account. Run manually at T+90 per
77
* specs/behaviors/account-migration.md#cutover-window-policy.
88
*
9-
* --dry-run prints the would-be send list and exits — no Resend calls, no
9+
* --dry-run prints the would-be send list and exits — no Postmark calls, no
1010
* disk writes. The CI test exercises only --dry-run.
1111
*
1212
* Usage:
1313
* npm run -w apps/api script:cutover-mailout -- --dry-run
1414
* npm run -w apps/api script:cutover-mailout -- --send --from=hello@codeforphilly.org
1515
*
1616
* Env:
17-
* RESEND_API_KEY — required for actual sends (otherwise --send refuses)
17+
* POSTMARK_SERVER_TOKEN — required for actual sends (otherwise --send refuses)
18+
* POSTMARK_MESSAGE_STREAM — optional; defaults to `outbound`
1819
* CFP_PUBLIC_URL — base URL used in the email body (defaults to
1920
* https://codeforphilly.org)
2021
* CFP_DATA_REPO_PATH + STORAGE_BACKEND + bucket envs — same shape as the API
2122
*/
2223
import { writeFile } from 'node:fs/promises';
2324
import { resolve } from 'node:path';
2425

26+
import { ServerClient } from 'postmark';
27+
28+
import { PostmarkTransport } from '../src/notify/postmark-transport.js';
2529
import { openPublicStore, type PublicStore } from '../src/store/public.js';
2630
import {
2731
FilesystemPrivateStore,
@@ -195,7 +199,7 @@ export async function runMailout(opts: MailoutOptions): Promise<MailoutReport> {
195199
}
196200

197201
// ---------------------------------------------------------------------------
198-
// Env wiring + Resend send
202+
// Env wiring + Postmark send
199203
// ---------------------------------------------------------------------------
200204

201205
function requireEnv(name: string): string {
@@ -220,33 +224,19 @@ function buildPrivateStore(): PrivateStore {
220224
});
221225
}
222226

223-
/** Resend HTTP send. Fetch-based to avoid adding a new dep at this stage. */
224-
async function resendSend(input: {
225-
to: string;
226-
from: string;
227-
subject: string;
228-
html: string;
229-
text: string;
230-
}): Promise<void> {
231-
const apiKey = requireEnv('RESEND_API_KEY');
232-
const res = await fetch('https://api.resend.com/emails', {
233-
method: 'POST',
234-
headers: {
235-
'authorization': `Bearer ${apiKey}`,
236-
'content-type': 'application/json',
237-
},
238-
body: JSON.stringify({
239-
from: input.from,
240-
to: input.to,
241-
subject: input.subject,
242-
html: input.html,
243-
text: input.text,
244-
}),
227+
/**
228+
* Postmark send via the same transport the API's notifier uses. The SDK
229+
* throws on any non-2xx, which runMailout() records per-recipient in
230+
* `failed` rather than aborting the run.
231+
*/
232+
function buildPostmarkSend(): NonNullable<MailoutOptions['send']> {
233+
const transport = new PostmarkTransport({
234+
client: new ServerClient(requireEnv('POSTMARK_SERVER_TOKEN')),
235+
messageStream: process.env['POSTMARK_MESSAGE_STREAM'] || undefined,
245236
});
246-
if (!res.ok) {
247-
const body = await res.text();
248-
throw new Error(`Resend ${res.status}: ${body.slice(0, 200)}`);
249-
}
237+
return async (input) => {
238+
await transport.send(input);
239+
};
250240
}
251241

252242
// ---------------------------------------------------------------------------
@@ -299,7 +289,7 @@ async function main(): Promise<void> {
299289
mode: args.dryRun ? 'dry-run' : 'send',
300290
from: args.from,
301291
publicUrl: args.publicUrl ?? process.env['CFP_PUBLIC_URL'],
302-
send: args.send ? resendSend : undefined,
292+
send: args.send ? buildPostmarkSend() : undefined,
303293
});
304294

305295
process.stderr.write(

‎apps/api/scripts/import-laddr/importer.ts‎

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ import {
9292
type TranslateCtx,
9393
type Warnings,
9494
} from './translators.js';
95-
import { BlobObject } from 'hologit';
95+
import type { BlobHandle } from 'gitsheets';
9696

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

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

505504
log(`[import] clear + upsert tags (${tags.length})`);
506505
await tx.tags.clear();
@@ -513,8 +512,8 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
513512
if (avatar) {
514513
// Mirror POST /api/people/:slug/avatar: store original + 128 thumb
515514
// as attachments and point avatarKey at the conventional path.
516-
const originalBlob = await BlobObject.write(hologit, avatar.original as unknown as string);
517-
const thumbnailBlob = await BlobObject.write(hologit, avatar.thumbnail as unknown as string);
515+
const originalBlob = await publicRepo.writeBlob(avatar.original);
516+
const thumbnailBlob = await publicRepo.writeBlob(avatar.thumbnail);
518517
await tx.people.setAttachments(p, {
519518
'avatar.jpg': originalBlob,
520519
'avatar-128.jpg': thumbnailBlob,
@@ -580,16 +579,10 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
580579
for (const { record } of blogTranslations) {
581580
const artifacts = mediaArtifactsBySlug.get(record.slug) ?? [];
582581
if (artifacts.length > 0) {
583-
const blobs: Record<string, BlobObject> = {};
582+
const blobs: Record<string, BlobHandle> = {};
584583
for (const a of artifacts) {
585-
// BlobObject.write hashes the buffer into the git object DB.
586-
// Same `as unknown as string` cast as the avatar route — the
587-
// declared signature is too narrow; the underlying
588-
// git-client `$putBlob` accepts Buffer at runtime.
589-
blobs[a.filename] = await BlobObject.write(
590-
hologit,
591-
a.bytes as unknown as string,
592-
);
584+
// repo.writeBlob hashes the Buffer into the git object DB.
585+
blobs[a.filename] = await publicRepo.writeBlob(a.bytes);
593586
}
594587
await tx['blog-posts'].setAttachments(record, blobs);
595588
}

‎apps/api/src/env.ts‎

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
*/
77
import { z } from 'zod';
88

9+
/**
10+
* Default SAML IdP entity ID. Stable across hosts — see the SAML_ENTITY_ID
11+
* field below and specs/api/saml.md#idp-identity-and-hosts.
12+
*/
13+
export const SAML_ENTITY_ID_DEFAULT = 'https://codeforphilly.org/api/saml/slack/metadata';
14+
915
export const EnvSchema = z.object({
1016
/** TCP port the Fastify server listens on. */
1117
PORT: z.coerce.number().default(3001),
@@ -49,8 +55,17 @@ export const EnvSchema = z.object({
4955
/** SAML IdP certificate (PEM) for the Slack SAML integration. */
5056
SAML_CERTIFICATE: z.string().optional(),
5157
/**
52-
* Slack workspace host. Used as the SAML `NameQualifier` per
53-
* specs/api/saml.md and shared with the `/chat` redirect handler.
58+
* SAML IdP entity ID — the metadata `entityID` and the `<Issuer>` on every
59+
* assertion. A stable logical identifier Slack stores at setup time, so it
60+
* deliberately does NOT follow CFP_SITE_HOST: the pre-cutover
61+
* `next.codeforphilly.org` deploy and the post-cutover `codeforphilly.org`
62+
* deploy present the same issuer. Per specs/api/saml.md#idp-identity-and-hosts.
63+
*/
64+
SAML_ENTITY_ID: z.url().default(SAML_ENTITY_ID_DEFAULT),
65+
/**
66+
* Slack workspace host. Used for the SAML ACS URL and `NameQualifier` per
67+
* specs/api/saml.md and shared with the `/chat` redirect handler. Never
68+
* used for our own IdP entity ID or endpoint URLs.
5469
*/
5570
SLACK_TEAM_HOST: z.string().default('codeforphilly.slack.com'),
5671
/**
@@ -64,19 +79,27 @@ export const EnvSchema = z.object({
6479
* `next-v2.codeforphilly.org` in sandbox). Used by the server-side
6580
* markdown renderer to distinguish internal from external links — anchors
6681
* with a host different from this one get `target="_blank" rel="noopener
67-
* nofollow"`. Per specs/behaviors/markdown-rendering.md.
82+
* nofollow"`. Per specs/behaviors/markdown-rendering.md. Also the host the
83+
* SAML IdP metadata advertises for its SSO endpoint Locations (per
84+
* specs/api/saml.md#idp-identity-and-hosts).
6885
*/
6986
CFP_SITE_HOST: z.string().default('codeforphilly.org'),
7087
/**
71-
* Resend API key for the email notifier. When unset, the services plugin
72-
* falls back to LoggingNotifier so dev + test runs don't need a real key.
73-
* See plans/notifier-email.md.
88+
* Postmark server token for the email notifier. When unset, the services
89+
* plugin falls back to LoggingNotifier so dev + test runs don't need a
90+
* real token. See plans/postmark-notifier.md.
91+
*/
92+
POSTMARK_SERVER_TOKEN: z.string().optional(),
93+
/**
94+
* Postmark message stream outbound mail is sent on. `outbound` is the
95+
* transactional default stream every Postmark server ships with. Only
96+
* relevant when POSTMARK_SERVER_TOKEN is set.
7497
*/
75-
RESEND_API_KEY: z.string().optional(),
98+
POSTMARK_MESSAGE_STREAM: z.string().default('outbound'),
7699
/**
77100
* From-address for outbound notifications. RFC 5322 form
78101
* (e.g. `"Code for Philly <notifications@codeforphilly.org>"`). Only
79-
* relevant when RESEND_API_KEY is set.
102+
* relevant when POSTMARK_SERVER_TOKEN is set.
80103
*/
81104
CFP_NOTIFICATION_FROM: z
82105
.string()
@@ -115,10 +138,12 @@ export const envJsonSchema = {
115138
CFP_JWT_SIGNING_KEY: { type: 'string', minLength: 1 },
116139
SAML_PRIVATE_KEY: { type: 'string' },
117140
SAML_CERTIFICATE: { type: 'string' },
141+
SAML_ENTITY_ID: { type: 'string', default: SAML_ENTITY_ID_DEFAULT },
118142
SLACK_TEAM_HOST: { type: 'string', default: 'codeforphilly.slack.com' },
119143
CFP_WEB_DIST_PATH: { type: 'string' },
120144
CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' },
121-
RESEND_API_KEY: { type: 'string' },
145+
POSTMARK_SERVER_TOKEN: { type: 'string' },
146+
POSTMARK_MESSAGE_STREAM: { type: 'string', default: 'outbound' },
122147
CFP_NOTIFICATION_FROM: {
123148
type: 'string',
124149
default: 'Code for Philly <notifications@codeforphilly.org>',

0 commit comments

Comments
 (0)