Files
readest/apps/readest-app/src/utils/access.ts
T
Huang Xin b78daed562 feat(send): gate email-in to Plus, Pro, and Lifetime plans (#4280)
Email-in (`<user>@readest.com`) is now a paid feature. The other
Send channels — in-app /send page, mobile share-sheet, browser
extension — stay open to free users.

Three enforcement layers:

- `pages/api/send/address.ts` and `pages/api/send/senders.ts` return
  403 with `{ code: 'plan_required', plan, requiredPlans }` for free
  users. No `send_addresses` row is allocated on the blocked path.
  `pages/api/send/inbox.ts` and `pages/api/send/inbox/file.ts` are
  deliberately left open — they're shared with the file-upload and
  extension channels.

- `workers/send-email` looks up `plans.plan` after resolving the
  recipient and bounces (not silently drops) inbound mail for free
  users with a one-sentence message pointing to upgrade plus the free
  clip channels. Bounce rather than drop so a downgraded user
  understands why their mail stops landing.

- `components/settings/integrations/SendToReadestForm.tsx` reads the
  user's plan from the JWT before any API call. Free users see one
  friendly card — headline, value prop, "View plans" CTA → /user, and
  a softer line about the free alternatives — instead of address /
  senders / activity sections of disabled controls. The
  IntegrationsPanel NavigationRow stays visible so users can discover
  the feature.

Single source of truth for the entitled tier set: `EMAIL_IN_PLANS` +
`isEmailInPlan(plan)` in `src/utils/access.ts`. Mirror copies live in
the Worker (no shared import surface) — keep them in sync.

Edge cases:
- Downgraded user: existing `send_addresses` row stays. All three
  layers block; re-upgrading silently restores the same address.
- Loading flicker: `userPlan` starts as `null` so the loading skeleton
  stays up rather than briefly flashing the upgrade card for a paid
  user on a slow client.

12 new unit tests cover the gate on `/api/send/address` and
`/api/send/senders` (GET + POST blocked for free users, no Supabase
access on the blocked path, allowed for plus / pro / purchase).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:30:32 +02:00

133 lines
4.2 KiB
TypeScript

import { jwtDecode } from 'jwt-decode';
import { supabase } from '@/utils/supabase';
import { UserPlan } from '@/types/quota';
import { DEFAULT_DAILY_TRANSLATION_QUOTA, DEFAULT_STORAGE_QUOTA } from '@/services/constants';
import { isWebAppPlatform } from '@/services/environment';
import { getDailyUsage } from '@/services/translators/utils';
import { getRuntimeConfig } from '@/services/runtimeConfig';
interface Token {
plan: UserPlan;
storage_usage_bytes: number;
storage_purchased_bytes: number;
[key: string]: string | number;
}
export const getSubscriptionPlan = (token: string): UserPlan => {
const data = jwtDecode<Token>(token) || {};
return data['plan'] || 'free';
};
export const getUserProfilePlan = (token: string): UserPlan => {
const data = jwtDecode<Token>(token) || {};
let plan = data['plan'] || 'free';
if (plan === 'free') {
const purchasedQuota = data['storage_purchased_bytes'] || 0;
if (purchasedQuota > 0) {
plan = 'purchase';
}
}
return plan;
};
/**
* Plans that include the "Send to Readest via email" feature: Plus,
* Pro, and Lifetime (`purchase`). Free users see an upgrade card on
* the client and get a 403 from the server endpoints that allocate /
* rotate the address, plus a bounce from the inbound email Worker.
*
* Other Send channels (in-app `/send` page, mobile share-sheet, browser
* extension) stay open to free users — the gate is the personal email
* inbox only.
*/
export const EMAIL_IN_PLANS: readonly UserPlan[] = ['plus', 'pro', 'purchase'];
export const isEmailInPlan = (plan: UserPlan): boolean =>
(EMAIL_IN_PLANS as readonly UserPlan[]).includes(plan);
export const STORAGE_QUOTA_GRACE_BYTES = 10 * 1024 * 1024; // 10 MB grace
export const getStoragePlanData = (token: string) => {
const data = jwtDecode<Token>(token) || {};
const plan = data['plan'] || 'free';
const usage = data['storage_usage_bytes'] || 0;
const purchasedQuota = data['storage_purchased_bytes'] || 0;
const runtimeConfig = getRuntimeConfig();
const fixedQuota =
runtimeConfig?.storageFixedQuota ?? parseInt(process.env['STORAGE_FIXED_QUOTA'] ?? '0');
const planQuota = fixedQuota || DEFAULT_STORAGE_QUOTA[plan] || DEFAULT_STORAGE_QUOTA['free'];
const quota = planQuota + purchasedQuota;
return {
plan,
usage,
quota,
};
};
export const getTranslationQuota = (plan: UserPlan): number => {
const runtimeConfig = getRuntimeConfig();
const fixedQuota =
runtimeConfig?.translationFixedQuota ?? parseInt(process.env['TRANSLATION_FIXED_QUOTA'] ?? '0');
return (
fixedQuota || DEFAULT_DAILY_TRANSLATION_QUOTA[plan] || DEFAULT_DAILY_TRANSLATION_QUOTA['free']
);
};
export const getTranslationPlanData = (token: string) => {
const data = jwtDecode<Token>(token) || {};
const plan: UserPlan = data['plan'] || 'free';
const usage = getDailyUsage() || 0;
const quota = getTranslationQuota(plan);
return {
plan,
usage,
quota,
};
};
export const getDailyTranslationPlanData = (token: string) => {
const data = jwtDecode<Token>(token) || {};
const plan = data['plan'] || 'free';
const quota = getTranslationQuota(plan);
return {
plan,
quota,
};
};
export const getAccessToken = async (): Promise<string | null> => {
// In browser context there might be two instances of supabase one in the app route
// and the other in the pages route, and they might have different sessions
// making the access token invalid for API calls. In that case we should use localStorage.
if (isWebAppPlatform()) {
return localStorage.getItem('token') ?? null;
}
const { data } = await supabase.auth.getSession();
return data?.session?.access_token ?? null;
};
export const getUserID = async (): Promise<string | null> => {
if (isWebAppPlatform()) {
const user = localStorage.getItem('user') ?? '{}';
return JSON.parse(user).id ?? null;
}
const { data } = await supabase.auth.getSession();
return data?.session?.user?.id ?? null;
};
export const validateUserAndToken = async (authHeader: string | null | undefined) => {
if (!authHeader) return {};
const token = authHeader.replace('Bearer ', '');
const {
data: { user },
error,
} = await supabase.auth.getUser(token);
if (error || !user) return {};
return { user, token };
};