Skip to content

Commit cddd746

Browse files
committed
feat: continue restart plan
1 parent eb434ea commit cddd746

17 files changed

Lines changed: 888 additions & 23 deletions

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 24 additions & 14 deletions
Large diffs are not rendered by default.

new-deepnotes/apps/api-worker/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"@deepnotes/db": "workspace:*",
1616
"@deepnotes/session": "workspace:*",
1717
"@upstash/redis": "^1.34.8",
18-
"hono": "^4.7.7"
18+
"hono": "^4.7.7",
19+
"stripe": "^17.7.0"
1920
},
2021
"devDependencies": {
2122
"@cloudflare/workers-types": "^4.20250426.0",

new-deepnotes/apps/api-worker/src/index.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ describe("api-worker", () => {
157157
["POST", "/api/users/me/2fa/recovery-codes"],
158158
["POST", "/api/users/me/2fa/devices/forget"],
159159
["POST", "/api/users/me/2fa/disable"],
160+
["POST", "/api/billing/stripe/checkout-session"],
161+
["POST", "/api/billing/stripe/portal-session"],
162+
["POST", "/api/webhooks/stripe"],
160163
] as const)("returns 503 for %s %s when auth env is not configured", async (method, path) => {
161164
const res = await app.request(`http://test${path}`, { method });
162165
expect(res.status).toBe(503);

new-deepnotes/apps/api-worker/src/index.ts

Lines changed: 193 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,22 @@ import {
3636
userEmailChangeRequestSchema,
3737
userPasswordChangeRequestSchema,
3838
userRegisterRequestSchema,
39+
stripeCheckoutSessionRequestSchema,
3940
} from "@deepnotes/api";
4041
import type { ContentfulStatusCode } from "hono/utils/http-status";
4142
import { Hono } from "hono";
4243
import type { PageMoveBody } from "@deepnotes/session";
44+
import Stripe from "stripe";
4345

4446
import { getDbForConnectionString } from "./db-pool.js";
4547
import { readCookieHeader } from "./cookies.js";
4648
import { getSessionRedisPort } from "./redis-port.js";
47-
import { getSessionEnv, type WorkerSessionBindings } from "./session-env.js";
49+
import {
50+
getSessionEnv,
51+
getStripeBillingEnv,
52+
getStripeWebhookSecret,
53+
type WorkerSessionBindings,
54+
} from "./session-env.js";
4855

4956
type Bindings = WorkerSessionBindings & {
5057
/** Wired in `wrangler.toml`; optional in unit tests that do not pass `env`. */
@@ -527,6 +534,7 @@ app.post("/api/users/me/email-change/confirm", async (c) => {
527534

528535
try {
529536
const { performUserEmailChangeConfirm } = await import("@deepnotes/session");
537+
const stripeKey = c.env.STRIPE_SECRET_KEY;
530538
const { cookieLines } = await performUserEmailChangeConfirm({
531539
db,
532540
env: sessionEnv,
@@ -536,6 +544,13 @@ app.post("/api/users/me/email-change/confirm", async (c) => {
536544
newLoginHash: parsed.data.newLoginHash,
537545
newEncryptedPrivateKeyring: parsed.data.userEncryptedPrivateKeyring,
538546
newEncryptedSymmetricKeyring: parsed.data.userEncryptedSymmetricKeyring,
547+
updateStripeCustomerEmail:
548+
stripeKey != null && stripeKey.length > 0
549+
? async (customerId: string, newEmail: string) => {
550+
const stripe = new Stripe(stripeKey);
551+
await stripe.customers.update(customerId, { email: newEmail });
552+
}
553+
: undefined,
539554
});
540555
const res = c.body(null, 204);
541556
appendSetCookies(res, cookieLines);
@@ -603,11 +618,19 @@ app.delete("/api/users/me", async (c) => {
603618

604619
try {
605620
const { performUserAccountDelete } = await import("@deepnotes/session");
621+
const stripeKey = c.env.STRIPE_SECRET_KEY;
606622
const { cookieLines } = await performUserAccountDelete({
607623
db,
608624
env: sessionEnv,
609625
accessCookie: readCookieHeader(cookieHeader, "accessToken"),
610626
loginHash,
627+
deleteStripeCustomer:
628+
stripeKey != null && stripeKey.length > 0
629+
? async (customerId: string) => {
630+
const stripe = new Stripe(stripeKey);
631+
await stripe.customers.del(customerId);
632+
}
633+
: undefined,
611634
});
612635
const res = c.body(null, 204);
613636
appendSetCookies(res, cookieLines);
@@ -3460,4 +3483,173 @@ app.post("/api/users/me/2fa/disable", async (c) => {
34603483
}
34613484
});
34623485

3486+
const billingNotConfiguredBody = {
3487+
code: "SERVICE_UNAVAILABLE" as const,
3488+
message:
3489+
"Stripe billing is not configured. Set STRIPE_SECRET_KEY, STRIPE_MONTHLY_PRICE_ID, and STRIPE_YEARLY_PRICE_ID (Wrangler secrets / .dev.vars).",
3490+
} as const;
3491+
3492+
const stripeWebhookNotConfiguredBody = {
3493+
code: "SERVICE_UNAVAILABLE" as const,
3494+
message:
3495+
"Stripe webhooks are not configured (STRIPE_WEBHOOK_SECRET).",
3496+
} as const;
3497+
3498+
app.post("/api/billing/stripe/checkout-session", async (c) => {
3499+
const sessionEnv = getSessionEnv(c.env);
3500+
if (sessionEnv == null) {
3501+
return c.json(serviceUnavailableBody, 503);
3502+
}
3503+
const hyper = c.env.HYPERDRIVE;
3504+
if (hyper == null) {
3505+
return c.json(
3506+
{
3507+
code: "SERVICE_UNAVAILABLE" as const,
3508+
message: "HYPERDRIVE binding is not configured.",
3509+
},
3510+
503,
3511+
);
3512+
}
3513+
const billing = getStripeBillingEnv(c.env);
3514+
if (billing == null) {
3515+
return c.json(billingNotConfiguredBody, 503);
3516+
}
3517+
3518+
let bodyJson: unknown = {};
3519+
try {
3520+
const t = await c.req.text();
3521+
if (t.length > 0) {
3522+
bodyJson = JSON.parse(t) as unknown;
3523+
}
3524+
} catch {
3525+
return c.json({ code: "BAD_REQUEST", message: "Expected JSON object." }, 400);
3526+
}
3527+
const parsed = stripeCheckoutSessionRequestSchema.safeParse(bodyJson);
3528+
if (!parsed.success) {
3529+
return c.json(
3530+
{
3531+
code: "VALIDATION_ERROR",
3532+
message: parsed.error.flatten().formErrors.join("; "),
3533+
},
3534+
400,
3535+
);
3536+
}
3537+
3538+
const db = getDbForConnectionString(hyper.connectionString);
3539+
const cookieHeader = c.req.header("Cookie");
3540+
try {
3541+
const { performStripeCreateCheckoutSession } = await import(
3542+
"@deepnotes/session"
3543+
);
3544+
const out = await performStripeCreateCheckoutSession({
3545+
db,
3546+
env: sessionEnv,
3547+
billing,
3548+
accessCookie: readCookieHeader(cookieHeader, "accessToken"),
3549+
requestOrigin: c.req.header("Origin") ?? undefined,
3550+
billingFrequency: parsed.data.billingFrequency,
3551+
});
3552+
return c.json(out, 200);
3553+
} catch (e) {
3554+
const { SessionError } = await import("@deepnotes/session");
3555+
if (e instanceof SessionError) {
3556+
return c.json(
3557+
{ code: e.code, message: e.message },
3558+
e.status as ContentfulStatusCode,
3559+
);
3560+
}
3561+
throw e;
3562+
}
3563+
});
3564+
3565+
app.post("/api/billing/stripe/portal-session", async (c) => {
3566+
const sessionEnv = getSessionEnv(c.env);
3567+
if (sessionEnv == null) {
3568+
return c.json(serviceUnavailableBody, 503);
3569+
}
3570+
const hyper = c.env.HYPERDRIVE;
3571+
if (hyper == null) {
3572+
return c.json(
3573+
{
3574+
code: "SERVICE_UNAVAILABLE" as const,
3575+
message: "HYPERDRIVE binding is not configured.",
3576+
},
3577+
503,
3578+
);
3579+
}
3580+
const billing = getStripeBillingEnv(c.env);
3581+
if (billing == null) {
3582+
return c.json(billingNotConfiguredBody, 503);
3583+
}
3584+
3585+
const db = getDbForConnectionString(hyper.connectionString);
3586+
const cookieHeader = c.req.header("Cookie");
3587+
try {
3588+
const { performStripeCreatePortalSession } = await import(
3589+
"@deepnotes/session"
3590+
);
3591+
const out = await performStripeCreatePortalSession({
3592+
db,
3593+
env: sessionEnv,
3594+
billing,
3595+
accessCookie: readCookieHeader(cookieHeader, "accessToken"),
3596+
});
3597+
return c.json(out, 200);
3598+
} catch (e) {
3599+
const { SessionError } = await import("@deepnotes/session");
3600+
if (e instanceof SessionError) {
3601+
return c.json(
3602+
{ code: e.code, message: e.message },
3603+
e.status as ContentfulStatusCode,
3604+
);
3605+
}
3606+
throw e;
3607+
}
3608+
});
3609+
3610+
app.post("/api/webhooks/stripe", async (c) => {
3611+
const hyper = c.env?.HYPERDRIVE;
3612+
if (hyper == null) {
3613+
return c.json(
3614+
{
3615+
code: "SERVICE_UNAVAILABLE" as const,
3616+
message: "HYPERDRIVE binding is not configured.",
3617+
},
3618+
503,
3619+
);
3620+
}
3621+
const webhookSecret = getStripeWebhookSecret(c.env);
3622+
if (webhookSecret == null) {
3623+
return c.json(stripeWebhookNotConfiguredBody, 503);
3624+
}
3625+
3626+
const rawBody = await c.req.text();
3627+
const db = getDbForConnectionString(hyper.connectionString);
3628+
3629+
try {
3630+
const {
3631+
parseStripeWebhookEvent,
3632+
processStripeWebhookEvent,
3633+
} = await import("@deepnotes/session");
3634+
const event = parseStripeWebhookEvent({
3635+
rawBody,
3636+
signature: c.req.header("Stripe-Signature") ?? c.req.header("stripe-signature"),
3637+
webhookSecret,
3638+
});
3639+
await processStripeWebhookEvent({ db, event });
3640+
return c.body(null, 200);
3641+
} catch (e) {
3642+
if (e instanceof Stripe.errors.StripeSignatureVerificationError) {
3643+
return c.json(
3644+
{ code: "BAD_REQUEST", message: "Invalid Stripe webhook signature." },
3645+
400,
3646+
);
3647+
}
3648+
if (e instanceof Error && e.message === "Missing Stripe-Signature header.") {
3649+
return c.json({ code: "BAD_REQUEST", message: e.message }, 400);
3650+
}
3651+
throw e;
3652+
}
3653+
});
3654+
34633655
export default app;

new-deepnotes/apps/api-worker/src/session-env.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { SessionEnv } from "@deepnotes/session";
1+
import type { SessionEnv, StripeBillingEnv } from "@deepnotes/session";
22

33
export type WorkerSessionBindings = {
44
ACCESS_SECRET?: string;
@@ -22,8 +22,44 @@ export type WorkerSessionBindings = {
2222
/** Optional; when set with token, failed-login rate limits use Upstash REST Redis. */
2323
UPSTASH_REDIS_REST_URL?: string;
2424
UPSTASH_REDIS_REST_TOKEN?: string;
25+
/** Stripe (`stripe` package); checkout, portal, customer hooks when set. */
26+
STRIPE_SECRET_KEY?: string;
27+
/** Webhook signing secret for `POST /api/webhooks/stripe`. */
28+
STRIPE_WEBHOOK_SECRET?: string;
29+
STRIPE_MONTHLY_PRICE_ID?: string;
30+
STRIPE_YEARLY_PRICE_ID?: string;
2531
};
2632

33+
export function getStripeBillingEnv(
34+
env: WorkerSessionBindings | undefined,
35+
): StripeBillingEnv | null {
36+
if (
37+
env?.STRIPE_SECRET_KEY == null ||
38+
env.STRIPE_SECRET_KEY === "" ||
39+
env.STRIPE_MONTHLY_PRICE_ID == null ||
40+
env.STRIPE_MONTHLY_PRICE_ID === "" ||
41+
env.STRIPE_YEARLY_PRICE_ID == null ||
42+
env.STRIPE_YEARLY_PRICE_ID === ""
43+
) {
44+
return null;
45+
}
46+
return {
47+
STRIPE_SECRET_KEY: env.STRIPE_SECRET_KEY,
48+
STRIPE_MONTHLY_PRICE_ID: env.STRIPE_MONTHLY_PRICE_ID,
49+
STRIPE_YEARLY_PRICE_ID: env.STRIPE_YEARLY_PRICE_ID,
50+
};
51+
}
52+
53+
export function getStripeWebhookSecret(
54+
env: WorkerSessionBindings | undefined,
55+
): string | null {
56+
const s = env?.STRIPE_WEBHOOK_SECRET;
57+
if (s == null || s === "") {
58+
return null;
59+
}
60+
return s;
61+
}
62+
2763
export function getSessionEnv(
2864
env: WorkerSessionBindings | undefined,
2965
): SessionEnv | null {

new-deepnotes/docs/DEPLOY_CLOUDFLARE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Set via **Wrangler secrets** or dashboard (never commit):
2121

2222
- Database: Hyperdrive handles pooling; app reads Hyperdrive binding, not raw remote URL in Worker code paths that should use the binding.
2323
- `JWT_SECRET` (or `ACCESS_SECRET` / `REFRESH_SECRET` if split to match legacy semantics)
24-
- `STRIPE_WEBHOOK_SECRET` when billing is wired
24+
- **Stripe (subscriptions):** `STRIPE_SECRET_KEY`, `STRIPE_MONTHLY_PRICE_ID`, `STRIPE_YEARLY_PRICE_ID` for `POST /api/billing/stripe/checkout-session` and `…/portal-session`; `STRIPE_WEBHOOK_SECRET` for `POST /api/webhooks/stripe` (raw body + `Stripe-Signature`). Optional: same `STRIPE_SECRET_KEY` powers `customers.del` / `customers.update` after account delete and email change when wired in the Worker.
2525
- `REDIS_URL` or vendor-specific vars for rate limits / sessions
2626

2727
### Preview (per PR / branch)

new-deepnotes/docs/TRPC_REST_MAP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ Working checklist for Phase 0 of [docs/RESTART_PLAN.md](../../docs/RESTART_PLAN.
2525
| `users.account.twoFactorAuth.generateRecoveryCodes` | `POST /api/users/me/2fa/recovery-codes` |
2626
| `users.account.twoFactorAuth.forgetTrustedDevices` | `POST /api/users/me/2fa/devices/forget` |
2727
| `users.account.twoFactorAuth.disable` | `POST /api/users/me/2fa/disable` |
28-
| `users.account.stripe.createCheckoutSession` | `POST /api/billing/stripe/checkout-session` |
29-
| `users.account.stripe.createPortalSession` | `POST /api/billing/stripe/portal-session` |
28+
| `users.account.stripe.createCheckoutSession` | `POST /api/billing/stripe/checkout-session` (**implemented** — optional body `{ "billingFrequency"?: "monthly" \| "yearly" }`; **200** `{ "checkoutSessionUrl" }`; requires verified email; `STRIPE_*` + Hyperdrive in worker) |
29+
| `users.account.stripe.createPortalSession` | `POST /api/billing/stripe/portal-session` (**implemented****200** `{ "portalSessionUrl" }`; requires `users.customer_id`) |
3030
| `users.account.delete` | `DELETE /api/users/me` (JSON body `{ "loginHash" }` base64; clears cookies on 204; optional `deleteStripeCustomer` in worker when billing is wired) |
3131
| (WS) `users.account.changePassword` step 1+2 | `POST /api/users/me/password` (JSON: `oldLoginHash`, `newLoginHash`, `userEncryptedPrivateKeyring`, `userEncryptedSymmetricKeyring` as base64; same keyring semantics as `POST /api/users`; 204 + clears cookies + invalidates all sessions) |
3232

@@ -104,7 +104,7 @@ Working checklist for Phase 0 of [docs/RESTART_PLAN.md](../../docs/RESTART_PLAN.
104104

105105
| Legacy | New |
106106
|--------|-----|
107-
| Stripe webhook (Fastify) | `POST /api/webhooks/stripe` |
107+
| Stripe webhook (Fastify) | `POST /api/webhooks/stripe` (**implemented** — raw body + `Stripe-Signature`; `customer.subscription.updated` / `customer.subscription.deleted`; maps user by `users.customer_id`) |
108108
| RevenueCat webhook | **not implemented** |
109109

110110
Reference routers: `apps/app-server/src/trpc/router.ts`, `apps/app-server/src/trpc/api/**`, `apps/app-server/src/websocket/**`.

new-deepnotes/packages/api/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ export {
6767
userPagesPathQuerySchema,
6868
userStartingPageResponseSchema,
6969
} from "./schemas/user-pages.js";
70+
export {
71+
stripeCheckoutSessionRequestSchema,
72+
stripeCheckoutSessionResponseSchema,
73+
stripePortalSessionResponseSchema,
74+
} from "./schemas/billing.js";
7075
export {
7176
emailVerificationConfirmRequestSchema,
7277
emailVerificationResendRequestSchema,

new-deepnotes/packages/api/src/openapi.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,5 +123,12 @@ describe("getOpenApiDocument", () => {
123123
expect(doc.paths?.["/api/pages/{pageId}"]?.delete).toBeDefined();
124124
expect(doc.paths?.["/api/pages/{pageId}/restore"]?.post).toBeDefined();
125125
expect(doc.paths?.["/api/pages/{pageId}/purge"]?.post).toBeDefined();
126+
expect(
127+
doc.paths?.["/api/billing/stripe/checkout-session"]?.post,
128+
).toBeDefined();
129+
expect(
130+
doc.paths?.["/api/billing/stripe/portal-session"]?.post,
131+
).toBeDefined();
132+
expect(doc.paths?.["/api/webhooks/stripe"]?.post).toBeDefined();
126133
});
127134
});

0 commit comments

Comments
 (0)