Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
PLAN_DEFINITIONS,
|
||||
TRIAL_DAYS,
|
||||
type Entitlements,
|
||||
type SubscriptionPlan,
|
||||
type SubscriptionStatus,
|
||||
} from "@app/shared-types";
|
||||
|
||||
export interface SubscriptionSnapshot {
|
||||
plan: SubscriptionPlan;
|
||||
status: SubscriptionStatus;
|
||||
expiresAt?: Date | null;
|
||||
gracePeriodExpiresAt?: Date | null;
|
||||
}
|
||||
|
||||
export interface TrialSnapshot {
|
||||
startedAt: Date;
|
||||
endsAt: Date;
|
||||
}
|
||||
|
||||
export interface UsageSnapshot {
|
||||
aiScansUsedThisMonth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend är source of truth (spec §47, §61.14). Denna funktion är den enda
|
||||
* platsen som avgör vad en användare får göra.
|
||||
*
|
||||
* Prioritet: aktiv betald prenumeration (inkl. grace) > pågående trial > free.
|
||||
*/
|
||||
export function computeEntitlements(
|
||||
subscription: SubscriptionSnapshot | null,
|
||||
trial: TrialSnapshot | null,
|
||||
usage: UsageSnapshot,
|
||||
now: Date = new Date(),
|
||||
): Entitlements {
|
||||
// 1. Betald prenumeration
|
||||
if (subscription) {
|
||||
const withinPeriod = subscription.expiresAt == null || subscription.expiresAt > now;
|
||||
const withinGrace =
|
||||
subscription.gracePeriodExpiresAt != null && subscription.gracePeriodExpiresAt > now;
|
||||
const active =
|
||||
(subscription.status === "active" || subscription.status === "trial") && withinPeriod;
|
||||
const inGrace = subscription.status === "in_grace" && withinGrace;
|
||||
|
||||
if (active || inGrace) {
|
||||
const def = PLAN_DEFINITIONS[subscription.plan];
|
||||
return {
|
||||
plan: subscription.plan,
|
||||
status: inGrace ? "in_grace" : subscription.status,
|
||||
maxHouseholdMembers: def.maxHouseholdMembers,
|
||||
aiScansPerMonth: def.aiScansPerMonth,
|
||||
aiScansUsedThisMonth: usage.aiScansUsedThisMonth,
|
||||
weekPlanning: def.weekPlanning,
|
||||
advancedNutrition: def.advancedNutrition,
|
||||
communityPublish: def.communityPublish,
|
||||
expiresAt: subscription.expiresAt?.toISOString(),
|
||||
graceUntil: subscription.gracePeriodExpiresAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Trial: 7 dagar full tillgång utan kort (spec §46). Family-nivå under trial.
|
||||
if (trial && trial.endsAt > now) {
|
||||
const def = PLAN_DEFINITIONS.family;
|
||||
return {
|
||||
plan: "family",
|
||||
status: "trial",
|
||||
maxHouseholdMembers: def.maxHouseholdMembers,
|
||||
aiScansPerMonth: def.aiScansPerMonth,
|
||||
aiScansUsedThisMonth: usage.aiScansUsedThisMonth,
|
||||
weekPlanning: true,
|
||||
advancedNutrition: true,
|
||||
communityPublish: true,
|
||||
expiresAt: trial.endsAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Gratisnivå: användbar men begränsad (spec §46).
|
||||
const free = PLAN_DEFINITIONS.free;
|
||||
return {
|
||||
plan: "free",
|
||||
status: "free",
|
||||
maxHouseholdMembers: free.maxHouseholdMembers,
|
||||
aiScansPerMonth: free.aiScansPerMonth,
|
||||
aiScansUsedThisMonth: usage.aiScansUsedThisMonth,
|
||||
weekPlanning: free.weekPlanning,
|
||||
advancedNutrition: free.advancedNutrition,
|
||||
communityPublish: free.communityPublish,
|
||||
};
|
||||
}
|
||||
|
||||
export function trialEndsAt(startedAt: Date): Date {
|
||||
return new Date(startedAt.getTime() + TRIAL_DAYS * 86_400_000);
|
||||
}
|
||||
|
||||
/** Har användaren AI-skanningar kvar denna månad? (fair use, spec §45) */
|
||||
export function canUseAiScan(entitlements: Entitlements): boolean {
|
||||
return entitlements.aiScansUsedThisMonth < entitlements.aiScansPerMonth;
|
||||
}
|
||||
|
||||
export function resolvePlanFromProductId(productId: string): SubscriptionPlan | null {
|
||||
for (const def of Object.values(PLAN_DEFINITIONS)) {
|
||||
if (def.appleProductId === productId || def.googleProductId === productId) return def.plan;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./entitlements.js";
|
||||
export * from "./token.js";
|
||||
export * from "./stores.js";
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { SubscriptionPlan, SubscriptionStatus } from "@app/shared-types";
|
||||
import { resolvePlanFromProductId } from "./entitlements.js";
|
||||
|
||||
/**
|
||||
* Butiksverifiering (spec §47, Del 13). Backend verifierar ALLTID köpet mot
|
||||
* butiken – klientens kvitto är bara ett påstående (spec §61.14).
|
||||
*
|
||||
* PRODUKTIONSINTEGRATION (implementeras i fas 7, se docs/utvecklingsfaser.md):
|
||||
* - Apple: App Store Server API v2 – verifiera JWS-signaturen i signedTransaction
|
||||
* mot Apples publika nycklar, kontrollera bundleId + environment.
|
||||
* - Google: Play Developer API purchases.subscriptionsv2.get med service account.
|
||||
*
|
||||
* Dev/CI: APP_STORE_MODE=sandbox accepterar särskilt formaterade
|
||||
* sandbox-payloads så att hela flödet kan testas end-to-end utan butikskonton.
|
||||
*/
|
||||
|
||||
export interface VerifiedPurchase {
|
||||
provider: "apple" | "google";
|
||||
productId: string;
|
||||
plan: SubscriptionPlan;
|
||||
originalTransactionId: string;
|
||||
status: SubscriptionStatus;
|
||||
purchasedAt: Date;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export type StoreVerificationResult =
|
||||
{ ok: true; purchase: VerifiedPurchase } | { ok: false; error: string };
|
||||
|
||||
export interface StoreVerifierConfig {
|
||||
mode: "production" | "sandbox";
|
||||
appleBundleId?: string | undefined;
|
||||
googlePackageName?: string | undefined;
|
||||
}
|
||||
|
||||
export class StoreVerifier {
|
||||
constructor(private readonly cfg: StoreVerifierConfig) {}
|
||||
|
||||
async verifyApple(signedTransaction: string): Promise<StoreVerificationResult> {
|
||||
if (this.cfg.mode === "sandbox") {
|
||||
return parseSandboxPayload("apple", signedTransaction);
|
||||
}
|
||||
// TODO(fas 7): App Store Server API v2 JWS-verifiering.
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Apple-verifiering i produktionsläge är inte konfigurerad ännu. " +
|
||||
"Kräver APPLE_ISSUER_ID/KEY_ID/PRIVATE_KEY (se docs/subscriptions.md).",
|
||||
};
|
||||
}
|
||||
|
||||
async verifyGoogle(
|
||||
packageName: string,
|
||||
productId: string,
|
||||
purchaseToken: string,
|
||||
): Promise<StoreVerificationResult> {
|
||||
if (this.cfg.mode === "sandbox") {
|
||||
return parseSandboxPayload("google", purchaseToken, productId);
|
||||
}
|
||||
if (this.cfg.googlePackageName && packageName !== this.cfg.googlePackageName) {
|
||||
return { ok: false, error: `Fel paketnamn: ${packageName}` };
|
||||
}
|
||||
// TODO(fas 7): Play Developer API purchases.subscriptionsv2.
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Google-verifiering i produktionsläge är inte konfigurerad ännu. " +
|
||||
"Kräver GOOGLE_SERVICE_ACCOUNT_JSON (se docs/subscriptions.md).",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox-payload: JSON i klartext:
|
||||
* {"productId":"<iosBundleId>.family_monthly","originalTransactionId":"sandbox-1","expiresInDays":30}
|
||||
*/
|
||||
function parseSandboxPayload(
|
||||
provider: "apple" | "google",
|
||||
payload: string,
|
||||
fallbackProductId?: string,
|
||||
): StoreVerificationResult {
|
||||
try {
|
||||
const data = JSON.parse(payload) as {
|
||||
productId?: string;
|
||||
originalTransactionId?: string;
|
||||
expiresInDays?: number;
|
||||
};
|
||||
const productId = data.productId ?? fallbackProductId;
|
||||
if (!productId) return { ok: false, error: "sandbox-payload saknar productId" };
|
||||
const plan = resolvePlanFromProductId(productId);
|
||||
if (!plan || plan === "free") {
|
||||
return { ok: false, error: `Okänt productId: ${productId}` };
|
||||
}
|
||||
const now = new Date();
|
||||
return {
|
||||
ok: true,
|
||||
purchase: {
|
||||
provider,
|
||||
productId,
|
||||
plan,
|
||||
originalTransactionId: data.originalTransactionId ?? `sandbox-${Date.now()}`,
|
||||
status: "active",
|
||||
purchasedAt: now,
|
||||
expiresAt: new Date(now.getTime() + (data.expiresInDays ?? 30) * 86_400_000),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, error: "Ogiltig sandbox-payload (förväntar JSON)" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import type { Entitlements } from "@app/shared-types";
|
||||
|
||||
/**
|
||||
* Signerad entitlement-token för offline-läge (spec §44):
|
||||
* "Premium får inte vara permanent lokal boolean. Använd signerad
|
||||
* entitlement-token med begränsad giltighet och grace period."
|
||||
*
|
||||
* Kompakt HS256-JWT implementerad med node:crypto (inga extra beroenden).
|
||||
* Appen cachar token och litar på den offline tills exp + grace har passerat.
|
||||
*/
|
||||
|
||||
export interface EntitlementTokenPayload {
|
||||
sub: string; // userId
|
||||
plan: Entitlements["plan"];
|
||||
status: string;
|
||||
maxMembers: number;
|
||||
scansPerMonth: number;
|
||||
weekPlanning: boolean;
|
||||
advancedNutrition: boolean;
|
||||
communityPublish: boolean;
|
||||
iat: number;
|
||||
exp: number;
|
||||
/** Unix-sekunder: sista tidpunkt token accepteras offline (grace). */
|
||||
graceExp: number;
|
||||
}
|
||||
|
||||
const b64url = (buf: Buffer): string =>
|
||||
buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
const fromB64url = (s: string): Buffer =>
|
||||
Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
||||
|
||||
export function signEntitlementToken(
|
||||
userId: string,
|
||||
entitlements: Entitlements,
|
||||
secret: string,
|
||||
options: { ttlHours?: number; graceDays?: number; now?: Date } = {},
|
||||
): string {
|
||||
const now = options.now ?? new Date();
|
||||
const ttlHours = options.ttlHours ?? 24;
|
||||
const graceDays = options.graceDays ?? 7;
|
||||
const iat = Math.floor(now.getTime() / 1000);
|
||||
const exp = iat + ttlHours * 3600;
|
||||
|
||||
const payload: EntitlementTokenPayload = {
|
||||
sub: userId,
|
||||
plan: entitlements.plan,
|
||||
status: entitlements.status,
|
||||
maxMembers: entitlements.maxHouseholdMembers,
|
||||
scansPerMonth: entitlements.aiScansPerMonth,
|
||||
weekPlanning: entitlements.weekPlanning,
|
||||
advancedNutrition: entitlements.advancedNutrition,
|
||||
communityPublish: entitlements.communityPublish,
|
||||
iat,
|
||||
exp,
|
||||
graceExp: exp + graceDays * 86_400,
|
||||
};
|
||||
|
||||
const header = b64url(Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })));
|
||||
const body = b64url(Buffer.from(JSON.stringify(payload)));
|
||||
const signature = b64url(createHmac("sha256", secret).update(`${header}.${body}`).digest());
|
||||
return `${header}.${body}.${signature}`;
|
||||
}
|
||||
|
||||
export type TokenVerification =
|
||||
| { valid: true; payload: EntitlementTokenPayload; withinGrace: boolean }
|
||||
| { valid: false; reason: "malformed" | "bad_signature" | "expired_beyond_grace" };
|
||||
|
||||
export function verifyEntitlementToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
now: Date = new Date(),
|
||||
): TokenVerification {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return { valid: false, reason: "malformed" };
|
||||
const [header, body, signature] = parts as [string, string, string];
|
||||
|
||||
const expected = createHmac("sha256", secret).update(`${header}.${body}`).digest();
|
||||
const actual = fromB64url(signature);
|
||||
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
||||
return { valid: false, reason: "bad_signature" };
|
||||
}
|
||||
|
||||
let payload: EntitlementTokenPayload;
|
||||
try {
|
||||
payload = JSON.parse(fromB64url(body).toString("utf8")) as EntitlementTokenPayload;
|
||||
} catch {
|
||||
return { valid: false, reason: "malformed" };
|
||||
}
|
||||
|
||||
const nowSec = Math.floor(now.getTime() / 1000);
|
||||
if (nowSec > payload.graceExp) return { valid: false, reason: "expired_beyond_grace" };
|
||||
return { valid: true, payload, withinGrace: nowSec > payload.exp };
|
||||
}
|
||||
Reference in New Issue
Block a user