pierre-review 0.1.57 → 0.1.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/api/plugins/auth.js +23 -4
  2. package/dist/api/routes/billing.js +204 -0
  3. package/dist/api/routes/me.js +4 -2
  4. package/dist/app.js +4 -0
  5. package/dist/auth/account.js +24 -0
  6. package/dist/coding/agent.js +1 -0
  7. package/dist/config.js +10 -2
  8. package/dist/db/migrations/0025_account_billing.sql +7 -0
  9. package/dist/db/migrations/meta/_journal.json +7 -0
  10. package/dist/db/migrations-pg/0014_wakeful_bill_hollister.sql +2 -0
  11. package/dist/db/migrations-pg/meta/0014_snapshot.json +2547 -0
  12. package/dist/db/migrations-pg/meta/_journal.json +7 -0
  13. package/dist/db/schema.pg.js +5 -1
  14. package/dist/db/schema.sqlite.js +8 -1
  15. package/dist/pro/contract.js +16 -2
  16. package/dist/review/agent.js +1 -0
  17. package/dist/review/pricing.js +3 -1
  18. package/package.json +2 -6
  19. package/public/assets/{index-BdkrDPhY.js → index-ChVJlk1B.js} +82 -82
  20. package/public/index.html +1 -1
  21. package/public-landing/assets/index-B6mKVK3l.js +40 -0
  22. package/public-landing/assets/index-BVeVh56_.css +1 -0
  23. package/public-landing/duel.svg +206 -0
  24. package/public-landing/index.html +39 -10
  25. package/public-landing/og-image.png +0 -0
  26. package/public-landing/shots/activity-feed-pro.png +0 -0
  27. package/public-landing/shots/activity-feed.png +0 -0
  28. package/public-landing/shots/ai-fix.png +0 -0
  29. package/public-landing/shots/claude-review.png +0 -0
  30. package/public-landing/shots/flow-fix-1-ci.png +0 -0
  31. package/public-landing/shots/flow-fix-2-analysis.png +0 -0
  32. package/public-landing/shots/flow-fix-3-diff.png +0 -0
  33. package/public-landing/shots/flow-fix-4-push.png +0 -0
  34. package/public-landing/shots/flow-metrics.png +0 -0
  35. package/public-landing/shots/flow-review-1-run.png +0 -0
  36. package/public-landing/shots/flow-review-2-memory.png +0 -0
  37. package/public-landing/shots/flow-review-3-findings.png +0 -0
  38. package/public-landing/shots/flow-review-4-post.png +0 -0
  39. package/public-landing/shots/insights.png +0 -0
  40. package/public-landing/shots/open-pr-strip.png +0 -0
  41. package/public-landing/shots/pr-detail.png +0 -0
  42. package/public-landing/shots/repo-console-free.png +0 -0
  43. package/public-landing/shots/repo-console.png +0 -0
  44. package/public-landing/shots/settings.png +0 -0
  45. package/public-landing/shots/sprint-report.png +0 -0
  46. package/public-landing/shots/timeline.png +0 -0
  47. package/public-landing/sitemap.xml +8 -8
  48. package/public-landing/assets/index-DntgTImq.js +0 -40
  49. package/public-landing/assets/index-myqL3hQ2.css +0 -1
  50. package/public-landing/shots/analytics.png +0 -0
  51. package/public-landing/shots/feed.png +0 -0
  52. package/public-landing/shots/focus-mode.png +0 -0
  53. package/public-landing/shots/insights-panel.png +0 -0
  54. package/public-landing/shots/my-turn.png +0 -0
@@ -30,6 +30,8 @@ export function registerAccountContext(app) {
30
30
  displayName: null,
31
31
  avatarUrl: null,
32
32
  isLocal: true,
33
+ plan: 'free',
34
+ stripeCustomerId: null,
33
35
  };
34
36
  }
35
37
  });
@@ -65,10 +67,14 @@ export async function registerSession(app) {
65
67
  },
66
68
  });
67
69
  }
68
- // Cloud only: 401 any unauthenticated /api data route. Skips /api/health and
69
- // /api/auth/* (sign-in itself) and all non-/api requests (the SPA + landing are
70
- // served openly; the frontend gate handles the signed-out UI). MUST be registered
71
- // AFTER registerAccountContext so req.account is already resolved.
70
+ // Cloud only: 401 any unauthenticated /api data route. Skips /api/health,
71
+ // /api/auth/* (sign-in itself), /api/billing/webhook (Stripe posts
72
+ // unauthenticated verified by signature instead) and all non-/api requests
73
+ // (the SPA + landing are served openly; the frontend gate handles the signed-out
74
+ // UI). MUST be registered AFTER registerAccountContext so req.account is already
75
+ // resolved. Also the cloud-only ENTITLEMENT gate: an authenticated free-plan
76
+ // account gets 402 on the Pro plugin's /api/pro/* routes (local accounts are
77
+ // always entitled — isLocal never reaches the cloud gate anyway).
72
78
  export function registerAuthGate(app) {
73
79
  app.addHook('onRequest', async (req, reply) => {
74
80
  const path = req.url.split('?')[0] ?? req.url;
@@ -78,11 +84,24 @@ export function registerAuthGate(app) {
78
84
  return;
79
85
  if (path.startsWith('/api/auth/'))
80
86
  return;
87
+ if (path === '/api/billing/webhook')
88
+ return;
89
+ // The pricing page's "Get Pro" is a plain browser navigation from the public
90
+ // landing; the route itself bounces anonymous visitors to sign-in instead of
91
+ // dead-ending them on a JSON 401.
92
+ if (path === '/api/billing/checkout')
93
+ return;
81
94
  if (!req.account) {
82
95
  await reply.code(401).send({
83
96
  error: 'Unauthorized',
84
97
  message: 'Sign in with GitHub to continue.',
85
98
  });
99
+ return;
100
+ }
101
+ if (path.startsWith('/api/pro/') &&
102
+ !req.account.isLocal &&
103
+ req.account.plan === 'free') {
104
+ await reply.code(402).send({ error: 'pro required' });
86
105
  }
87
106
  });
88
107
  }
@@ -0,0 +1,204 @@
1
+ // Stripe billing seam ("carve the path"): a checkout redirect + the webhook that
2
+ // flips `accounts.plan`. Deliberately dependency-free — signature verification is
3
+ // hand-rolled with node:crypto (no stripe SDK), and the whole feature is inert
4
+ // until STRIPE_PAYMENT_LINK_URL / STRIPE_WEBHOOK_SECRET are set (the webhook
5
+ // replies 501 unconfigured). Registered unconditionally in app.ts, both modes.
6
+ //
7
+ // Entitlement flows from `accounts.plan`: /api/me intersects the capability
8
+ // singleton with the plan (pro/contract.ts entitledProCapabilities) and the cloud
9
+ // auth gate 402s free-plan /api/pro/* requests (api/plugins/auth.ts). Local
10
+ // accounts are always fully entitled — this file never affects local behavior.
11
+ import { createHmac, timingSafeEqual } from 'node:crypto';
12
+ import { config } from '../../config.js';
13
+ import { getAccountById, getAccountByStripeCustomerId, setAccountPlan, } from '../../auth/account.js';
14
+ // Stripe's default signature tolerance: reject events older/newer than 5 minutes
15
+ // (replay protection).
16
+ export const STRIPE_SIGNATURE_TOLERANCE_SEC = 300;
17
+ /**
18
+ * Verify a Stripe webhook signature (the `stripe-signature` header) without the
19
+ * stripe SDK. Header format: `t=<unix-seconds>,v1=<hex>[,v1=<hex>…]` (multiple
20
+ * v1 entries appear during secret rotation). The signed payload is
21
+ * `<t>.<rawBody>`; the signature is HMAC-SHA256(secret) over it, hex-encoded.
22
+ * Compared timing-safely against every v1; the timestamp must be within
23
+ * STRIPE_SIGNATURE_TOLERANCE_SEC of `nowMs`.
24
+ */
25
+ export function verifyStripeSignature(rawBody, header, secret, nowMs = Date.now()) {
26
+ let ts = null;
27
+ const v1s = [];
28
+ for (const part of header.split(',')) {
29
+ const idx = part.indexOf('=');
30
+ if (idx < 0)
31
+ continue;
32
+ const key = part.slice(0, idx).trim();
33
+ const value = part.slice(idx + 1).trim();
34
+ if (!value)
35
+ continue;
36
+ if (key === 't') {
37
+ const n = Number.parseInt(value, 10);
38
+ if (Number.isFinite(n))
39
+ ts = n;
40
+ }
41
+ else if (key === 'v1') {
42
+ v1s.push(value);
43
+ }
44
+ }
45
+ if (ts == null || v1s.length === 0)
46
+ return false;
47
+ if (Math.abs(nowMs / 1000 - ts) > STRIPE_SIGNATURE_TOLERANCE_SEC)
48
+ return false;
49
+ const expected = createHmac('sha256', secret)
50
+ .update(`${ts}.`)
51
+ .update(rawBody)
52
+ .digest('hex');
53
+ const expectedBuf = Buffer.from(expected, 'utf-8');
54
+ return v1s.some((sig) => {
55
+ const sigBuf = Buffer.from(sig, 'utf-8');
56
+ return (sigBuf.length === expectedBuf.length && timingSafeEqual(sigBuf, expectedBuf));
57
+ });
58
+ }
59
+ // Stripe `customer` fields are a string id or an expanded object with `.id`.
60
+ function customerIdOf(value) {
61
+ if (typeof value === 'string' && value !== '')
62
+ return value;
63
+ if (value != null &&
64
+ typeof value === 'object' &&
65
+ typeof value.id === 'string') {
66
+ return value.id;
67
+ }
68
+ return null;
69
+ }
70
+ /** Map a parsed Stripe event to the plan action it implies. Pure. */
71
+ export function stripeEventAction(event) {
72
+ if (event == null || typeof event !== 'object')
73
+ return { kind: 'ignore' };
74
+ const { type, data } = event;
75
+ if (typeof type !== 'string')
76
+ return { kind: 'ignore' };
77
+ const obj = (data?.object ?? null);
78
+ if (obj == null || typeof obj !== 'object')
79
+ return { kind: 'ignore' };
80
+ if (type === 'checkout.session.completed' ||
81
+ type === 'checkout.session.async_payment_succeeded') {
82
+ // Delayed-notification methods (SEPA, ACH…) fire `completed` with
83
+ // payment_status 'unpaid' and confirm later via async_payment_succeeded —
84
+ // only grant once the money actually landed. `no_payment_required` covers
85
+ // trials.
86
+ const paid = obj['payment_status'] === 'paid' ||
87
+ obj['payment_status'] === 'no_payment_required';
88
+ if (!paid)
89
+ return { kind: 'ignore' };
90
+ const ref = obj['client_reference_id'];
91
+ const accountId = typeof ref === 'string' && /^\d+$/.test(ref)
92
+ ? Number.parseInt(ref, 10)
93
+ : typeof ref === 'number' && Number.isInteger(ref)
94
+ ? ref
95
+ : null;
96
+ return {
97
+ kind: 'checkout_completed',
98
+ accountId,
99
+ stripeCustomerId: customerIdOf(obj['customer']),
100
+ };
101
+ }
102
+ if (type === 'customer.subscription.updated' ||
103
+ type === 'customer.subscription.deleted') {
104
+ const status = obj['status'];
105
+ const active = type !== 'customer.subscription.deleted' &&
106
+ (status === 'active' || status === 'trialing');
107
+ return {
108
+ kind: 'subscription_status',
109
+ customerId: customerIdOf(obj['customer']),
110
+ plan: active ? 'pro' : 'free',
111
+ };
112
+ }
113
+ return { kind: 'ignore' };
114
+ }
115
+ export async function billingRoutes(app) {
116
+ // Redirect the signed-in user to the Stripe Payment Link, tagging the checkout
117
+ // with their account id (client_reference_id rides the Payment Link URL and
118
+ // comes back on checkout.session.completed). Exempted from the cloud auth gate
119
+ // (it's a plain browser navigation from the public pricing page), so it must
120
+ // handle anonymous visitors itself: bounce them to sign-in rather than a 401.
121
+ app.get('/api/billing/checkout', async (req, reply) => {
122
+ if (!config.stripePaymentLinkUrl) {
123
+ return reply.redirect('/pricing?checkout=unavailable', 302);
124
+ }
125
+ if (!req.account) {
126
+ return reply.redirect('/api/auth/login', 302);
127
+ }
128
+ const url = new URL(config.stripePaymentLinkUrl);
129
+ url.searchParams.set('client_reference_id', String(req.account.id));
130
+ return reply.redirect(url.toString(), 302);
131
+ });
132
+ // The webhook needs the RAW request body (the signature signs the exact bytes).
133
+ // Fastify content-type parsers are ENCAPSULATED, so registering the buffer
134
+ // parser inside this nested plugin scope affects ONLY the routes declared here
135
+ // — the rest of the API keeps normal JSON parsing (proven by billing.test.ts).
136
+ await app.register(async (scope) => {
137
+ scope.addContentTypeParser('application/json', { parseAs: 'buffer' }, (_req, body, done) => {
138
+ done(null, body);
139
+ });
140
+ // Stripe posts unauthenticated (exempted from the auth gate); authenticity
141
+ // comes from the signature. Never 500 on an unknown account — Stripe retries
142
+ // on non-2xx, and an unknown account is a data mismatch, not a server fault.
143
+ scope.post('/api/billing/webhook', async (req, reply) => {
144
+ if (!config.stripeWebhookSecret) {
145
+ return reply.code(501).send({ error: 'billing not configured' });
146
+ }
147
+ const raw = req.body;
148
+ if (!Buffer.isBuffer(raw)) {
149
+ return reply.code(400).send({ error: 'expected a JSON body' });
150
+ }
151
+ const sig = req.headers['stripe-signature'];
152
+ if (typeof sig !== 'string' ||
153
+ !verifyStripeSignature(raw, sig, config.stripeWebhookSecret)) {
154
+ return reply.code(400).send({ error: 'invalid signature' });
155
+ }
156
+ let event;
157
+ try {
158
+ event = JSON.parse(raw.toString('utf-8'));
159
+ }
160
+ catch {
161
+ return reply.code(400).send({ error: 'invalid JSON' });
162
+ }
163
+ const action = stripeEventAction(event);
164
+ if (action.kind === 'checkout_completed') {
165
+ const account = action.accountId != null ? await getAccountById(action.accountId) : null;
166
+ if (account) {
167
+ // client_reference_id is attacker-influencable (it rides the public
168
+ // Payment Link URL), so never let a new checkout re-bind an account
169
+ // that already has a DIFFERENT Stripe customer — that would orphan
170
+ // the original subscription's webhooks.
171
+ const rebind = account.stripeCustomerId != null &&
172
+ action.stripeCustomerId != null &&
173
+ account.stripeCustomerId !== action.stripeCustomerId;
174
+ await setAccountPlan(account.id, 'pro', rebind ? undefined : action.stripeCustomerId);
175
+ if (rebind) {
176
+ req.log.warn({
177
+ accountId: account.id,
178
+ existingCustomerId: account.stripeCustomerId,
179
+ newCustomerId: action.stripeCustomerId,
180
+ }, 'stripe checkout for an account already bound to a different customer — kept the existing binding');
181
+ }
182
+ req.log.info({ accountId: account.id }, 'stripe checkout completed — plan set to pro');
183
+ }
184
+ else {
185
+ req.log.warn({ clientReferenceId: action.accountId }, 'stripe checkout completed for unknown account — ignored');
186
+ }
187
+ }
188
+ else if (action.kind === 'subscription_status') {
189
+ const account = action.customerId
190
+ ? await getAccountByStripeCustomerId(action.customerId)
191
+ : null;
192
+ if (account) {
193
+ await setAccountPlan(account.id, action.plan);
194
+ req.log.info({ accountId: account.id, plan: action.plan }, 'stripe subscription status — plan updated');
195
+ }
196
+ else {
197
+ req.log.warn({ customerId: action.customerId }, 'stripe subscription event for unknown customer — ignored');
198
+ }
199
+ }
200
+ return { received: true };
201
+ });
202
+ });
203
+ }
204
+ //# sourceMappingURL=billing.js.map
@@ -1,7 +1,7 @@
1
1
  import { config } from '../../config.js';
2
2
  import { accountToLocalUser } from '../../auth/account.js';
3
3
  import { accountIdOf } from '../plugins/auth.js';
4
- import { getProCapabilities } from '../../pro/contract.js';
4
+ import { EMPTY_CAPABILITIES, entitledProCapabilities, } from '../../pro/contract.js';
5
5
  import { getFyiProvider } from '../../feed/fyi-provider.js';
6
6
  import { dismissMyTurn, getCompletedDismissals, getFeedLastSeenAt, getMyTurn, markFeedSeen, undismissMyTurn, } from '../../db/queries.js';
7
7
  const dismissSchema = {
@@ -44,7 +44,9 @@ export async function meRoutes(app) {
44
44
  newFeedItems,
45
45
  // Claude Review is now the Pro `claudeReview` capability (in `pro` below).
46
46
  deploymentMode: config.deploymentMode,
47
- pro: getProCapabilities(),
47
+ // Per-account entitlement: local = full capabilities (unchanged); cloud =
48
+ // full only when the account's plan isn't 'free' (Stripe billing seam).
49
+ pro: req.account ? entitledProCapabilities(req.account) : EMPTY_CAPABILITIES,
48
50
  };
49
51
  });
50
52
  app.get('/api/my-turn', async (req) => getMyTurn(accountIdOf(req)));
package/dist/app.js CHANGED
@@ -19,6 +19,7 @@ import { feedRoutes } from './api/routes/feed.js';
19
19
  import { mergersRoutes } from './api/routes/mergers.js';
20
20
  import { insightsRoutes } from './api/routes/insights.js';
21
21
  import { activityRoutes } from './api/routes/activity.js';
22
+ import { billingRoutes } from './api/routes/billing.js';
22
23
  export async function buildApp() {
23
24
  const app = Fastify({
24
25
  // In production (the installed CLI) the per-request "incoming request" /
@@ -131,6 +132,9 @@ export async function buildApp() {
131
132
  await app.register(mergersRoutes);
132
133
  await app.register(insightsRoutes);
133
134
  await app.register(activityRoutes);
135
+ // Stripe billing seam (checkout redirect + webhook). Registered in both modes;
136
+ // inert until the STRIPE_* env vars are set (webhook 501s unconfigured).
137
+ await app.register(billingRoutes);
134
138
  // Claude Review moved into the @pierre/pro plugin (its routes register there, gated on the
135
139
  // `claudeReview` capability). The SDK-run / diff-prep / GitHub-post infra + the tables stay
136
140
  // in core behind the ctx.review seam; nothing to register here.
@@ -32,6 +32,8 @@ function rowToAccount(row) {
32
32
  displayName: row.displayName,
33
33
  avatarUrl: row.avatarUrl,
34
34
  isLocal: row.isLocal,
35
+ plan: row.plan === 'pro' ? 'pro' : 'free',
36
+ stripeCustomerId: row.stripeCustomerId,
35
37
  };
36
38
  }
37
39
  // Module cache of the local account, set by ensureLocalAccount() at startup so
@@ -178,6 +180,28 @@ export async function getAccountById(id) {
178
180
  .execute();
179
181
  return rows[0] ? rowToAccount(rows[0]) : null;
180
182
  }
183
+ /**
184
+ * Set an account's billing plan (and, when known, its Stripe customer id).
185
+ * Called only by the Stripe webhook handler (api/routes/billing.ts).
186
+ */
187
+ export async function setAccountPlan(accountId, plan, stripeCustomerId) {
188
+ const { accounts } = schema;
189
+ const set = { plan };
190
+ if (stripeCustomerId != null)
191
+ set.stripeCustomerId = stripeCustomerId;
192
+ await db.update(accounts).set(set).where(eq(accounts.id, accountId)).execute();
193
+ }
194
+ /** Resolve an account by its Stripe customer id (subscription webhooks). */
195
+ export async function getAccountByStripeCustomerId(customerId) {
196
+ const { accounts } = schema;
197
+ const rows = await db
198
+ .select()
199
+ .from(accounts)
200
+ .where(eq(accounts.stripeCustomerId, customerId))
201
+ .limit(1)
202
+ .execute();
203
+ return rows[0] ? rowToAccount(rows[0]) : null;
204
+ }
181
205
  /**
182
206
  * Resolve an account's owner to a row in `users` (by login) if they've appeared
183
207
  * in any synced repo. Returns null when "you" haven't authored/acted anywhere
@@ -44,6 +44,7 @@ const RESOLVE_TOOLS = [
44
44
  const RESOLVE_DISALLOWED_TOOLS = ['Bash', 'NotebookEdit'];
45
45
  // Models that accept the `effort` option (Haiku 4.5 rejects it — the API 400s).
46
46
  const EFFORT_CAPABLE_MODELS = new Set([
47
+ 'claude-sonnet-5',
47
48
  'claude-opus-4-8',
48
49
  'claude-sonnet-4-6',
49
50
  ]);
package/dist/config.js CHANGED
@@ -128,9 +128,17 @@ export const config = {
128
128
  // HSTS_MAX_AGE=0 as a kill switch. `preload` is intentionally NOT sent — it is
129
129
  // hard to undo; opt in manually once the domain is proven.
130
130
  hstsMaxAge: intFromEnv('HSTS_MAX_AGE', 31536000),
131
+ // ---- Stripe billing (optional; NOT required by assertCloudConfig) ----
132
+ // Payment Link URL for the Pro plan — GET /api/billing/checkout 302s to it with
133
+ // client_reference_id=<accountId> appended. Empty = checkout unavailable.
134
+ stripePaymentLinkUrl: process.env.STRIPE_PAYMENT_LINK_URL ?? '',
135
+ // Webhook signing secret (whsec_…) for POST /api/billing/webhook. Empty = the
136
+ // webhook replies 501 and no billing state ever changes.
137
+ stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? '',
131
138
  // Pro plugin master gate. Pro is local-only for now; bind.ts skips the dynamic
132
- // import entirely when false.
133
- proEnabled: !isCloud,
139
+ // import entirely when false. PRO_DISABLED=true forces pure-OSS mode even when
140
+ // the submodule is checked out (used to exercise/capture the free tier).
141
+ proEnabled: !isCloud && process.env.PRO_DISABLED !== 'true',
134
142
  // Per-repo digest (Pro, Workstream 2) config — consumed by @pierre/pro, kept in
135
143
  // core so the model id / budgets live in one place and aren't hardcoded at call
136
144
  // sites. Inert until the plugin is present AND digestEnabled.
@@ -0,0 +1,7 @@
1
+ -- Stripe billing seam (additive). `plan` ('free' | 'pro', default 'free') is set ONLY by
2
+ -- the Stripe webhook (api/routes/billing.ts) — the OAuth upsert never touches it, so a
3
+ -- re-login can't reset a paid plan. `stripe_customer_id` (cus_…) is captured from
4
+ -- checkout.session.completed so subscription webhooks can resolve the account.
5
+ -- The Postgres baseline is regenerated separately via `pnpm db:generate:pg`.
6
+ ALTER TABLE `accounts` ADD `plan` text NOT NULL DEFAULT 'free';--> statement-breakpoint
7
+ ALTER TABLE `accounts` ADD `stripe_customer_id` text;
@@ -176,6 +176,13 @@
176
176
  "when": 1780800000015,
177
177
  "tag": "0024_pr_head_ref_name",
178
178
  "breakpoints": true
179
+ },
180
+ {
181
+ "idx": 25,
182
+ "version": "6",
183
+ "when": 1783296000000,
184
+ "tag": "0025_account_billing",
185
+ "breakpoints": true
179
186
  }
180
187
  ]
181
188
  }
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "accounts" ADD COLUMN "plan" text DEFAULT 'free' NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "accounts" ADD COLUMN "stripe_customer_id" text;