The Garage SDKs, in the open

garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
This commit is contained in:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
@@ -0,0 +1,21 @@
// Offline licence keys for Garage apps — layered on garage_auth.
//
// One key per entitlement, verified against the store's published JWKS. The
// key is the gate; the ledger is the truth behind it.
//
// final ent = GarageEntitlements(
// auth: auth,
// projectSlug: "field-notes",
// apiBaseUrl: "https://pay.imbenji.net/api",
// );
// await ent.cached(); // boot, no network
// await ent.refresh(); // when theres a connection
// if (ent.has("pro-annual")) unlock();
library;
export "src/garage_entitlements.dart"
show GarageEntitlements, kGaragePortalBaseUrl, kGarageLicenceIssuer;
export "src/models.dart"
show GarageKey, GarageEntitlement, GarageProduct, EntitlementsError;
export "src/key_cache.dart"
show KeyCache, SecureKeyCache, MemoryKeyCache, CachedKeys;
@@ -0,0 +1,391 @@
import "dart:async";
import "dart:convert";
import "package:flutter/foundation.dart";
import "package:garage_auth/garage_auth.dart";
import "package:http/http.dart" as http;
import "jwks_verify.dart";
import "key_cache.dart";
import "models.dart";
/// Where the payment portal lives. Overridable per call, but this is the one
/// buyers actually land on.
const String kGaragePortalBaseUrl = String.fromEnvironment(
"GARAGE_PORTAL_BASE_URL",
defaultValue: "https://pay.imbenji.net",
);
/// What `iss` has to say. A constant, checked against — not something the
/// token gets to tell us.
const String kGarageLicenceIssuer = String.fromEnvironment(
"GARAGE_LICENCE_ISSUER",
defaultValue: "https://pay.imbenji.net",
);
/// Offline licence keys for one project.
///
/// One key per entitlement. Money bought one thing, so a key unlocks that one
/// thing — a project-wide licence would be a wallet, and handing a wallet to a
/// lock that only cares about one feature tells it everything the user owns.
///
/// final ent = GarageEntitlements(
/// auth: auth,
/// projectSlug: "field-notes",
/// apiBaseUrl: "https://pay.imbenji.net/api",
/// );
/// await ent.cached(); // fast, no network
/// await ent.refresh(); // when you have a connection
/// if (ent.has("pro-annual")) { ... }
///
/// A ChangeNotifier, so a ListenableBuilder redraws the gates when the set
/// moves.
class GarageEntitlements extends ChangeNotifier {
GarageEntitlements({
required this.auth,
required this.projectSlug,
required this.apiBaseUrl,
KeyCache? cache,
this.mode = "live",
this.issuer = kGarageLicenceIssuer,
}) : _cache = cache ?? SecureKeyCache();
final GarageAuth auth;
final String projectSlug;
final String apiBaseUrl;
/// Which side of the ledger you expect. A sandbox key must never satisfy a
/// live check, so this is checked rather than believed — set it to "sandbox"
/// while the project is, and back when it goes live.
final String mode;
/// The `iss` a key has to carry.
final String issuer;
final KeyCache _cache;
/// sku -> verified key. The gate reads this.
final Map<String, GarageKey> _keys = {};
/// Every verified key we currently hold, sku -> key.
Map<String, GarageKey> get keys => Map.unmodifiable(_keys);
Uri _u(String path, [Map<String, String>? q]) {
final base = apiBaseUrl.endsWith("/")
? apiBaseUrl.substring(0, apiBaseUrl.length - 1)
: apiBaseUrl;
return Uri.parse("$base$path").replace(queryParameters: q);
}
// -------------------------------------------------------------------------
// the gate
// -------------------------------------------------------------------------
/// The verified key for [sku], or null. This is the gate — it is synchronous
/// and it never touches the network. Call [cached] once at boot and [refresh]
/// when you have a connection.
GarageKey? key(String sku) {
final k = _keys[sku];
if (k == null) return null;
// held keys are checked at load, but a long running app can sit past one.
if (k.isExpired) {
_keys.remove(sku);
return null;
}
return k;
}
/// Do they own [sku] right now.
bool has(String sku) => key(sku) != null;
// -------------------------------------------------------------------------
// fetching
// -------------------------------------------------------------------------
/// Pull the whole key set for this project and REPLACE what we hold.
///
/// Replace, not merge. A cancelled subscription simply stops coming back in
/// the response — merging would leave its key sat there working untill its
/// own exp, which is exactly the bug this shape exists to avoid.
///
/// Every key is verified BEFORE anything is written. A response we cant fully
/// check leaves the previous set alone rather than half-applying it.
///
/// [ttl] asks for a shorter key than the ceiling would give. Asking for more
/// is not an error, it is just quietly clamped.
Future<Map<String, GarageKey>> refresh({Duration? ttl}) async {
_requireSignedIn();
final query = <String, String>{"project": projectSlug};
if (ttl != null) query["ttl"] = "${ttl.inSeconds}";
final resp = await auth.client.get(_u("/v1/licences", query));
if (resp.statusCode != 200) {
throw _errorFrom(resp, fallback: "Could not fetch keys.");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final jwks = body["jwks"];
if (jwks is! Map) {
throw EntitlementsError(
"No jwks in the response — the keys cant be checked.",
code: "no_jwks",
);
}
final jwksDoc = Map<String, dynamic>.from(jwks);
final sub = await _subject();
// verify the lot first. the envelope's sku is a convenience; the verified
// token is the only thing we key off.
final verified = <String, GarageKey>{};
final tokens = <String, String>{};
for (final raw in (body["licences"] as List? ?? const [])) {
if (raw is! Map) continue;
final token = raw["licence"] as String?;
if (token == null || token.isEmpty) continue;
final aud = peekAudience(token);
if (aud == null) {
throw EntitlementsError(
"A key came back with no usable audience.",
code: "bad_token",
);
}
final key = verifyKey(
token,
jwksDoc,
expectedIssuer: issuer,
expectedProject: projectSlug,
expectedSku: aud.sku,
expectedSub: sub,
expectedMode: mode,
);
verified[key.sku] = key;
tokens[key.sku] = token;
}
// only now does anything change.
await _cache.write(projectSlug, CachedKeys(keys: tokens, jwks: jwksDoc));
_keys
..clear()
..addAll(verified);
notifyListeners();
return keys;
}
/// Load and verify the cached set. Zero network — this is the boot path, and
/// the one that keeps working on a plane.
///
/// A key that no longer verifies (expired, rotated away, signed for somebody
/// else) is dropped and logged rather than throwing, so one dead key doesnt
/// take the whole set with it.
Future<Map<String, GarageKey>> cached() async {
final blob = await _cache.read(projectSlug);
if (blob == null) {
_keys.clear();
notifyListeners();
return keys;
}
final String sub;
try {
sub = await _subject();
} catch (error, stack) {
// no profile to match against — offline and never signed in properly.
print("[garage_entitlements] cant resolve the subject: $error");
print(stack);
_keys.clear();
notifyListeners();
return keys;
}
final loaded = <String, GarageKey>{};
blob.keys.forEach((sku, token) {
try {
loaded[sku] = verifyKey(
token,
blob.jwks,
expectedIssuer: issuer,
expectedProject: projectSlug,
expectedSku: sku,
expectedSub: sub,
expectedMode: mode,
);
} catch (error) {
// expected often enough (an expired key is the normal case) that a
// stack would be noise — but never silent.
print("[garage_entitlements] dropping cached key '$sku': $error");
}
});
_keys
..clear()
..addAll(loaded);
notifyListeners();
return keys;
}
/// The ledger, straight from the server. The truth, and a fallback for when
/// key signing is switched off on a deployment.
///
/// Gates should ask [key] / [has] instead — this is a network call, it says
/// nothing offline, and it hands back the whole account's grants for the
/// project rather than the one thing you were asking about. Good for a
/// purchase screen ("you allready own this"), wrong for a feature check.
Future<List<GarageEntitlement>> entitlements() async {
_requireSignedIn();
final resp = await auth.client.get(_u("/v1/entitlements"));
if (resp.statusCode != 200) {
throw _errorFrom(resp, fallback: "Could not load entitlements.");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
return (body["entitlements"] as List? ?? const [])
.whereType<Map>()
.map((m) => GarageEntitlement.fromJson(Map<String, dynamic>.from(m)))
.toList();
}
/// One product by sku, off the public portal lookup. Null when there isnt
/// one. No bearer needed, though we send one if we have it so `owned` comes
/// back meaning something.
Future<GarageProduct?> productBySku(String sku) async {
final uri = _u("/v1/portal/by-sku/$projectSlug/$sku");
final resp = auth.isSignedIn
? await auth.client.get(uri)
: await http.get(uri);
if (resp.statusCode == 404) return null;
if (resp.statusCode != 200) {
throw _errorFrom(resp, fallback: "Could not load that product.");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final product = body["product"];
if (product is! Map) return null;
return GarageProduct.fromJson(Map<String, dynamic>.from(product));
}
// -------------------------------------------------------------------------
// buying
// -------------------------------------------------------------------------
/// The portal url for [productId], with a one-shot handoff code on it so the
/// buyer doesnt sign in twice.
///
/// Returns a Uri — LAUNCHING it is yours. This package has no url_launcher
/// dependency and isnt going to grow one; you allready have a way to open a
/// url and it isnt this package's business what it is.
///
/// [returnUrl] has to be registered on the project or checkout refuses it.
Future<Uri> portalUrl(
String productId, {
String? returnUrl,
String? portalBaseUrl,
}) async {
_requireSignedIn();
final base = portalBaseUrl ?? kGaragePortalBaseUrl;
final trimmed = base.endsWith("/")
? base.substring(0, base.length - 1)
: base;
final query = <String, String>{};
if (returnUrl != null && returnUrl.isNotEmpty) {
query["return_url"] = returnUrl;
}
final code = await _mintHandoffCode();
if (code != null) query["handoff"] = code;
return Uri.parse(
"$trimmed/pay/$productId",
).replace(queryParameters: query.isEmpty ? null : query);
}
/// One-shot code from auth, or null when we couldnt get one — the portal
/// still works then, the buyer just signs in when they land.
Future<String?> _mintHandoffCode() async {
try {
final resp = await http.post(
Uri.parse("${auth.issuer}/auth/handoff/issue"),
headers: {"Authorization": "Bearer ${auth.accessToken}"},
);
if (resp.statusCode != 200) {
print(
"[garage_entitlements] handoff issue refused "
"(${resp.statusCode}): ${resp.body}",
);
return null;
}
final json = jsonDecode(resp.body) as Map<String, dynamic>;
return json["handoff_code"] as String?;
} catch (error, stack) {
print("[garage_entitlements] couldnt mint a handoff code: $error");
print(stack);
return null;
}
}
// -------------------------------------------------------------------------
/// Throw the cached set away. Sign-out should call this — the keys are for
/// whoever was signed in, and the sub check would reject them anyway, but
/// leaving them on disk is untidy.
Future<void> clear() async {
await _cache.clear(projectSlug);
_keys.clear();
notifyListeners();
}
// resolve the user id a key has to be for. profile() round trips userinfo,
// which is fine — its behind the authed client and only wanted at load time.
String? _cachedSub;
Future<String> _subject() async {
if (_cachedSub != null) return _cachedSub!;
final me = await auth.profile();
final sub = me?["sub"] as String?;
if (sub == null || sub.isEmpty) {
throw EntitlementsError(
"No subject in the profile — cant match a key to a user.",
code: "no_subject",
);
}
_cachedSub = sub;
return sub;
}
void _requireSignedIn() {
if (!auth.isSignedIn) {
throw EntitlementsError(
"Not signed in — call auth.signIn() first.",
code: "not_signed_in",
);
}
}
EntitlementsError _errorFrom(http.Response resp, {required String fallback}) {
try {
final b = jsonDecode(resp.body) as Map<String, dynamic>;
final code = b["error"] as String?;
final message = b["message"] as String? ?? fallback;
return EntitlementsError(message, code: code);
} catch (error) {
// a non-json body (a gateway page, usually) — still worth saying.
print(
"[garage_entitlements] ${resp.statusCode} with an unreadable body: "
"$error",
);
return EntitlementsError("$fallback (${resp.statusCode})");
}
}
}
@@ -0,0 +1,211 @@
import "dart:convert";
import "dart:typed_data";
import "package:pointycastle/export.dart";
import "models.dart";
// Offline key verification. The store signs a key RS256 and publishes the
// matching public half as a JWKS; we only ever hold that half, so we can check
// a key but never mint one.
//
// No jwt library here on purpose. A JWKS RSA verify is "split the compact
// token, rebuild the public key from n/e, check PKCS1v15 SHA-256 over
// header.payload", and pointycastle — the same stack the backend signs with —
// does exactly that.
/// Verify [token] against [jwks] and the caller's expectations.
///
/// The order is fixed and it matters: signature, then `iss`, then `aud`, then
/// `sub`, then `mode`, then `exp`. Anything failing means not entitled — every
/// failure is an [EntitlementsError] so a caller can treat them alike.
///
/// `aud` is `"<project>/<sku>"`, which is what lets a lock check a key knowing
/// only its own sku and the public key.
GarageKey verifyKey(
String token,
Map<String, dynamic> jwks, {
required String expectedIssuer,
required String expectedProject,
required String expectedSku,
required String expectedSub,
required String expectedMode,
}) {
final parts = token.split(".");
if (parts.length != 3) {
throw EntitlementsError("Malformed key token.", code: "bad_token");
}
final header = _decodeJsonSegment(parts[0]);
final payload = _decodeJsonSegment(parts[1]);
final alg = header["alg"] as String?;
if (alg != "RS256") {
throw EntitlementsError("Unexpected key alg: $alg", code: "bad_alg");
}
// 1. signature.
final kid = header["kid"] as String?;
final key = _findKey(jwks, kid);
if (key == null) {
// rotation: the kid isnt in the jwks we hold. a fresh refresh pulls the new
// jwks down with the keys, so this sorts itself out next time we're online.
throw EntitlementsError("No JWKS key for kid '$kid'.",
code: "kid_not_found");
}
final signingInput = utf8.encode("${parts[0]}.${parts[1]}");
final signature = _b64UrlBytes(parts[2]);
if (!_verifyRs256(key, Uint8List.fromList(signingInput), signature)) {
throw EntitlementsError("Key signature failed.", code: "bad_signature");
}
// 2. iss.
final iss = payload["iss"] as String?;
if (iss == null || iss != expectedIssuer) {
throw EntitlementsError("Key issuer mismatch.", code: "iss_mismatch");
}
// 3. aud — one string, "<project>/<sku>".
final aud = payload["aud"];
final wanted = "$expectedProject/$expectedSku";
if (aud is! String || aud != wanted) {
throw EntitlementsError("Key audience mismatch.", code: "aud_mismatch");
}
// 4. sub.
final sub = payload["sub"] as String?;
if (sub == null || sub != expectedSub) {
throw EntitlementsError("Key subject mismatch.", code: "sub_mismatch");
}
// 5. mode. a sandbox key must never satisfy a live check.
final mode = payload["mode"] as String?;
if (mode == null || mode != expectedMode) {
throw EntitlementsError("Key mode mismatch.", code: "mode_mismatch");
}
final iat = _epoch(payload["iat"]);
final exp = _epoch(payload["exp"]);
if (exp == null) {
throw EntitlementsError("Key has no exp.", code: "no_exp");
}
final rawEntExpiry = payload["expires_at"] as String?;
final verified = GarageKey(
subject: sub,
issuer: iss,
project: payload["project"] as String? ?? expectedProject,
sku: payload["sku"] as String? ?? expectedSku,
kind: payload["kind"] as String? ?? "one_off",
mode: mode,
issuedAt: iat ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
expiresAt: exp,
entitlementExpiresAt: (rawEntExpiry == null || rawEntExpiry.isEmpty)
? null
: DateTime.tryParse(rawEntExpiry)?.toUtc(),
);
// 6. exp. trusts the device clock when offline, which is an accepted
// limitation — the alternative is refusing to work on a plane.
if (verified.isExpired) {
throw EntitlementsError("Key expired.", code: "expired");
}
return verified;
}
/// The `aud` of a compact token without verifying anything — used to work out
/// which sku a cached key belongs to before we know what to check it against.
/// Never trust what comes out of here; it is a routing hint, nothing more.
({String project, String sku})? peekAudience(String token) {
try {
final parts = token.split(".");
if (parts.length != 3) return null;
final payload = _decodeJsonSegment(parts[1]);
final aud = payload["aud"];
if (aud is! String) return null;
final slash = aud.indexOf("/");
if (slash <= 0 || slash == aud.length - 1) return null;
return (project: aud.substring(0, slash), sku: aud.substring(slash + 1));
} catch (error, stack) {
print("[garage_entitlements] couldnt peek at a token's aud: $error");
print(stack);
return null;
}
}
// ---- key lookup ----
// pull the RSA public key for a kid out of the jwks. a header with no kid and
// exactly one key in the doc is fine — take that one.
RSAPublicKey? _findKey(Map<String, dynamic> jwks, String? kid) {
final keys = jwks["keys"];
if (keys is! List || keys.isEmpty) return null;
Map<String, dynamic>? match;
for (final k in keys) {
if (k is! Map) continue;
final m = Map<String, dynamic>.from(k);
if (m["kty"] != "RSA") continue;
if (kid == null || m["kid"] == kid) {
match = m;
break;
}
}
if (match == null) return null;
final nB = match["n"] as String?;
final eB = match["e"] as String?;
if (nB == null || eB == null) return null;
return RSAPublicKey(
_bytesToBigInt(_b64UrlBytes(nB)),
_bytesToBigInt(_b64UrlBytes(eB)),
);
}
bool _verifyRs256(RSAPublicKey key, Uint8List input, Uint8List sig) {
final verifier = Signer("SHA-256/RSA") as RSASigner;
verifier.init(false, PublicKeyParameter<RSAPublicKey>(key));
try {
return verifier.verifySignature(input, RSASignature(sig));
} catch (error, stack) {
// a mangled signature throws rather than coming back false.
print("[garage_entitlements] key verify threw: $error");
print(stack);
return false;
}
}
// ---- small codec helpers ----
Map<String, dynamic> _decodeJsonSegment(String seg) {
final bytes = _b64UrlBytes(seg);
return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
}
Uint8List _b64UrlBytes(String s) {
// jwt segments are base64url with the padding stripped — put it back.
var out = s.replaceAll("-", "+").replaceAll("_", "/");
final pad = out.length % 4;
if (pad > 0) out = out.padRight(out.length + (4 - pad), "=");
return base64.decode(out);
}
BigInt _bytesToBigInt(List<int> bytes) {
var r = BigInt.zero;
for (final b in bytes) {
r = (r << 8) | BigInt.from(b & 0xff);
}
return r;
}
DateTime? _epoch(Object? v) {
if (v == null) return null;
final n = v is int ? v : int.tryParse("$v");
if (n == null) return null;
return DateTime.fromMillisecondsSinceEpoch(n * 1000, isUtc: true);
}
@@ -0,0 +1,96 @@
import "dart:convert";
import "package:flutter_secure_storage/flutter_secure_storage.dart";
// Where the keys live between runs. ONE blob per project holding the JWKS and
// the whole key set, written together — there is deliberately no "keys but no
// jwks" state, because that is a set of tokens you cannot check.
//
// Same secure storage garage_auth keeps the tokens in. A key is low value (it
// is public-key verifiable and short lived), but keeping it beside the tokens
// means one place to look for sdk state.
/// The seam, so tests and odd platforms can swap the backend out.
abstract class KeyCache {
Future<CachedKeys?> read(String projectSlug);
Future<void> write(String projectSlug, CachedKeys value);
Future<void> clear(String projectSlug);
}
/// The whole cached set for one project. [keys] is sku -> compact JWT.
class CachedKeys {
CachedKeys({required this.keys, required this.jwks});
final Map<String, String> keys;
/// the jwks doc as fetched ( {"keys":[...]} )
final Map<String, dynamic> jwks;
Map<String, dynamic> toJson() => {"keys": keys, "jwks": jwks};
factory CachedKeys.fromJson(Map<String, dynamic> j) => CachedKeys(
keys: Map<String, String>.from(j["keys"] as Map),
jwks: Map<String, dynamic>.from(j["jwks"] as Map),
);
}
class SecureKeyCache implements KeyCache {
SecureKeyCache({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
String _k(String slug) => "ge.keys.$slug";
@override
Future<CachedKeys?> read(String projectSlug) async {
try {
final raw = await _storage.read(key: _k(projectSlug));
if (raw == null || raw.isEmpty) return null;
return CachedKeys.fromJson(jsonDecode(raw) as Map<String, dynamic>);
} catch (error, stack) {
// a cache we cant read is the same as no cache — say so and carry on.
print("[garage_entitlements] key cache read failed: $error");
print(stack);
return null;
}
}
@override
Future<void> write(String projectSlug, CachedKeys value) async {
try {
await _storage.write(
key: _k(projectSlug),
value: jsonEncode(value.toJson()),
);
} catch (error, stack) {
print("[garage_entitlements] key cache write failed: $error");
print(stack);
}
}
@override
Future<void> clear(String projectSlug) async {
try {
await _storage.delete(key: _k(projectSlug));
} catch (error, stack) {
print("[garage_entitlements] key cache clear failed: $error");
print(stack);
}
}
}
/// In memory. For tests, or anywhere you explicitly dont want persistence.
class MemoryKeyCache implements KeyCache {
final Map<String, CachedKeys> _m = {};
@override
Future<CachedKeys?> read(String projectSlug) async => _m[projectSlug];
@override
Future<void> write(String projectSlug, CachedKeys value) async =>
_m[projectSlug] = value;
@override
Future<void> clear(String projectSlug) async => _m.remove(projectSlug);
}
+174
View File
@@ -0,0 +1,174 @@
// The shapes this package deals in. A key, an entitlement row, and just enough
// of a product to put a price on a buy button.
/// A verified licence key. One entitlement, one key — the key unlocks the thing
/// that was bought and says nothing about the rest of the account.
///
/// Two clocks live on here and they are not the same thing:
///
/// * [expiresAt] is the KEY's expiry. It is the access decision. The server
/// allready clamped it against everything below, so checking it alone is
/// correct.
/// * [entitlementExpiresAt] is when the SUBSCRIPTION runs out, if it ever
/// does. Informational — the thing a UI says out loud ("renews on the 3rd").
class GarageKey {
GarageKey({
required this.subject,
required this.issuer,
required this.project,
required this.sku,
required this.kind,
required this.mode,
required this.issuedAt,
required this.expiresAt,
this.entitlementExpiresAt,
});
/// the user id (`sub`)
final String subject;
final String issuer;
final String project;
final String sku;
/// "one_off" or "subscription"
final String kind;
/// "live" or "sandbox". A sandbox key never satisfies a live check.
final String mode;
final DateTime issuedAt;
/// The key's own expiry (`exp`). THIS is the gate.
final DateTime expiresAt;
/// The entitlement's expiry (`expires_at`), when it has one. Null for
/// something owned outright. Never the gate — for showing a date.
final DateTime? entitlementExpiresAt;
bool get isSubscription => kind == "subscription";
bool get isExpired => DateTime.now().toUtc().isAfter(expiresAt);
/// How long this key has left. Negative once it has gone.
Duration get timeLeft => expiresAt.difference(DateTime.now().toUtc());
}
/// A row from the entitlement ledger. The truth, when you are online.
class GarageEntitlement {
GarageEntitlement({
required this.productId,
required this.kind,
required this.status,
required this.active,
this.projectId,
this.source,
this.expiresAt,
this.graceUntil,
});
final String productId;
final String? projectId;
final String kind;
/// the raw provider state — 'active' | 'trialing' | 'past_due' | 'expired' |
/// 'refunded' | 'revoked'. Read [active] instead, unless you are showing
/// somebody why.
final String status;
final String? source;
/// null = owned outright.
final DateTime? expiresAt;
/// set while a renewal is bouncing; access holds untill it passes.
final DateTime? graceUntil;
/// the server's own verdict — status, expiry and grace allready folded in.
final bool active;
factory GarageEntitlement.fromJson(Map<String, dynamic> j) {
final exp = j["expires_at"] as String?;
final grace = j["grace_until"] as String?;
return GarageEntitlement(
productId: j["product_id"] as String? ?? "",
projectId: j["project_id"] as String?,
kind: j["kind"] as String? ?? "app",
status: j["status"] as String? ?? "expired",
source: j["source"] as String?,
expiresAt: (exp == null || exp.isEmpty) ? null : DateTime.tryParse(exp),
graceUntil:
(grace == null || grace.isEmpty) ? null : DateTime.tryParse(grace),
active: j["active"] == true,
);
}
}
/// Enough of a product to build a purchase screen. Comes off the public
/// by-sku lookup, so there is no bearer needed and nothing secret in it.
class GarageProduct {
GarageProduct({
required this.id,
required this.sku,
required this.name,
required this.kind,
required this.priceMinor,
required this.currency,
this.description,
this.interval,
this.trialDays,
this.active = true,
});
/// a uuid. Treat it as opaque, dont parse it
final String id;
final String sku;
final String name;
final String? description;
/// 'app' or 'subscription' — the catalogue's own wording, which is not quite
/// the key's `kind`. A key says "one_off"; the catalogue still says "app".
final String kind;
final int priceMinor;
final String currency;
/// 'month' | 'year' | null
final String? interval;
final int? trialDays;
final bool active;
bool get isSubscription => kind == "subscription";
bool get isFree => priceMinor <= 0;
factory GarageProduct.fromJson(Map<String, dynamic> j) => GarageProduct(
id: j["id"] as String? ?? "",
sku: j["sku"] as String? ?? "",
name: j["name"] as String? ?? "",
description: j["description"] as String?,
kind: j["kind"] as String? ?? "app",
priceMinor: (j["price_minor"] as num?)?.toInt() ?? 0,
currency: j["currency"] as String? ?? "gbp",
interval: j["interval"] as String?,
trialDays: (j["trial_days"] as num?)?.toInt(),
active: j["active"] == true || j["active"] == 1,
);
/// a rough display price — no FX, no locale. Your UI does the pretty bit.
String get displayPrice {
if (isFree) return "Free";
return "${currency.toUpperCase()} ${(priceMinor / 100.0).toStringAsFixed(2)}";
}
}
/// Everything this package throws. Network errors from the authed client come
/// up as themselves.
class EntitlementsError implements Exception {
EntitlementsError(this.message, {this.code});
final String message;
final String? code;
@override
String toString() => code == null ? message : "$message ($code)";
}