forked from akai/readest
b78daed562
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>
133 lines
4.2 KiB
TypeScript
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 };
|
|
};
|