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
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@app/subscriptions",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Entitlements, signerade offline-tokens och StoreKit/Play-verifiering (spec §4447)",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@app/shared-types": "workspace:*"
}
}
+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 };
}
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { PLAN_DEFINITIONS } from "@app/shared-types";
import {
canUseAiScan,
computeEntitlements,
resolvePlanFromProductId,
signEntitlementToken,
trialEndsAt,
verifyEntitlementToken,
} from "../src/index.js";
const NOW = new Date("2026-08-02T12:00:00Z");
const SECRET = "test-secret-with-length";
describe("entitlements (spec §4547)", () => {
it("aktiv prenumeration ger planens förmåner", () => {
const ent = computeEntitlements(
{ plan: "family", status: "active", expiresAt: new Date("2026-09-01") },
null,
{ aiScansUsedThisMonth: 10 },
NOW,
);
expect(ent.plan).toBe("family");
expect(ent.maxHouseholdMembers).toBe(6);
expect(ent.weekPlanning).toBe(true);
});
it("grace period behåller åtkomst (spec §44)", () => {
const ent = computeEntitlements(
{
plan: "household",
status: "in_grace",
expiresAt: new Date("2026-08-01"),
gracePeriodExpiresAt: new Date("2026-08-10"),
},
null,
{ aiScansUsedThisMonth: 0 },
NOW,
);
expect(ent.status).toBe("in_grace");
expect(ent.weekPlanning).toBe(true);
});
it("utgången prenumeration utan grace → free", () => {
const ent = computeEntitlements(
{ plan: "family", status: "active", expiresAt: new Date("2026-07-01") },
null,
{ aiScansUsedThisMonth: 0 },
NOW,
);
expect(ent.plan).toBe("free");
});
it("trial ger full tillgång i 7 dagar (spec §46)", () => {
const start = new Date("2026-08-01T00:00:00Z");
const ent = computeEntitlements(
null,
{ startedAt: start, endsAt: trialEndsAt(start) },
{ aiScansUsedThisMonth: 3 },
NOW,
);
expect(ent.status).toBe("trial");
expect(ent.weekPlanning).toBe(true);
});
it("free tier: 10 skanningar och användbar grund (spec §46)", () => {
const ent = computeEntitlements(null, null, { aiScansUsedThisMonth: 9 }, NOW);
expect(ent.plan).toBe("free");
expect(ent.aiScansPerMonth).toBe(10);
expect(canUseAiScan(ent)).toBe(true);
expect(canUseAiScan({ ...ent, aiScansUsedThisMonth: 10 })).toBe(false);
});
it("mappar productId → plan (härlett ur brand-konfig, aldrig hårdkodat)", () => {
expect(resolvePlanFromProductId(PLAN_DEFINITIONS.family.appleProductId)).toBe("family");
expect(resolvePlanFromProductId(PLAN_DEFINITIONS.household.googleProductId)).toBe("household");
expect(resolvePlanFromProductId("okänd")).toBeNull();
});
});
describe("signerad entitlement-token (spec §44)", () => {
const ent = computeEntitlements(
{ plan: "family", status: "active", expiresAt: new Date("2026-09-01") },
null,
{ aiScansUsedThisMonth: 0 },
NOW,
);
it("signerar och verifierar", () => {
const token = signEntitlementToken("user-1", ent, SECRET, { now: NOW });
const result = verifyEntitlementToken(token, SECRET, NOW);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.payload.sub).toBe("user-1");
expect(result.payload.plan).toBe("family");
expect(result.withinGrace).toBe(false);
}
});
it("avvisar manipulerad token", () => {
const token = signEntitlementToken("user-1", ent, SECRET, { now: NOW });
const tampered = token.slice(0, -4) + "AAAA";
expect(verifyEntitlementToken(tampered, SECRET, NOW).valid).toBe(false);
});
it("avvisar fel secret", () => {
const token = signEntitlementToken("user-1", ent, SECRET, { now: NOW });
expect(verifyEntitlementToken(token, "annan-secret-123", NOW).valid).toBe(false);
});
it("grace: giltig efter exp men inom graceExp, ogiltig därefter ALDRIG permanent boolean", () => {
const token = signEntitlementToken("user-1", ent, SECRET, {
now: NOW,
ttlHours: 24,
graceDays: 7,
});
const afterExp = new Date(NOW.getTime() + 3 * 86_400_000);
const inGrace = verifyEntitlementToken(token, SECRET, afterExp);
expect(inGrace.valid).toBe(true);
if (inGrace.valid) expect(inGrace.withinGrace).toBe(true);
const afterGrace = new Date(NOW.getTime() + 9 * 86_400_000);
const expired = verifyEntitlementToken(token, SECRET, afterGrace);
expect(expired.valid).toBe(false);
if (!expired.valid) expect(expired.reason).toBe("expired_beyond_grace");
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"],
"compilerOptions": {
"types": ["node"]
}
}