Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+107
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./entitlements.js";
export * from "./token.js";
export * from "./stores.js";
+110
View File
@@ -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)" };
}
}
+94
View File
@@ -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 };
}