Tokens come out of garage_ui/tool/export_design_tokens_test.dart, the components are CSS on top of them, and the cards cover foundations, components and whole hub and Arcs & Angles screens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
Garage SDKs
Flutter packages for building apps against Garage — sign in, offline licence checks, in-app purchasing, and the design system every Garage app is drawn with.
| Package | What it does |
|---|---|
garage_auth |
Sign in with Garage. OIDC Authorization Code + PKCE against the hub, token storage with refresh, and a shared authed HTTP client. Everything else builds on it. |
garage_entitlements |
Offline licence keys. One signed key per entitlement, verified against the store's published JWKS, so has("pro") works with no network. |
garage_iap |
In-app purchasing for the app-store storefront — products, an embedded Stripe sheet or browser handoff, and an offline-verifiable licence. The storefront is parked; new apps want garage_entitlements. |
garage_ui |
The design system. Compact, desktop-first widgets and theme — shadcn-shaped API, Blender-shaped rendering. No Material. |
None of the packages hold secrets. garage_auth, garage_entitlements and
garage_iap only ever call public endpoints, with the signed-in user's own
bearer, and the licence packages only hold the public half of the signing key
— they can check a key, they can never mint one.
Everything here is MIT licensed, see LICENSE.
Install
The packages arent on pub.dev. Git-depend on this repo, pick the package with
path:, and pin the commit of a release tag:
dependencies:
garage_auth:
git:
url: https://git.imbenji.dev/IMBENJI.NET/Garage-SDKs.git
path: garage_auth
ref: b2692019197e60d44ddd3ecab4917caf71ff45c3 # v0.1.0
garage_entitlements:
git:
url: https://git.imbenji.dev/IMBENJI.NET/Garage-SDKs.git
path: garage_entitlements
ref: b2692019197e60d44ddd3ecab4917caf71ff45c3 # v0.1.0
Pin, dont float main. garage_entitlements decides who gets the paid
features, and "it changed under us" is not a fun thing to debug.
Use the tag's full commit hash, not the tag name. garage_entitlements and
garage_iap pull in garage_auth by relative path, which pub turns into a git
dep at the resolved commit hash — so if your app asks for garage_auth at
v0.1.0, pub sees two different sources for the same package and refuses to
resolve. Same hash on every garage package and it just works. Keep the tag name
in a comment so you can tell which release it is.
You'll be constructing a GarageAuth yourself, so declare garage_auth
directly even when you only really want entitlements or iap.
Working on the SDKs at the same time as an app? Point the app at a local
checkout with a gitignored pubspec_overrides.yaml, so edits hot reload with
no push or ref bump. See docs/local-development.md.
Hosts
Two of them, and mixing them up is the usual way to lose an afternoon:
https://hub.imbenji.net/auth-api identity — the OIDC issuer, for garage_auth
https://pay.imbenji.net/api commerce — products, entitlements, licence keys
https://pay.imbenji.net the hosted checkout buyers land on
garage_auth
import "package:garage_auth/garage_auth.dart";
final auth = GarageAuth(
issuer: "https://hub.imbenji.net/auth-api",
clientId: "my-app", // your own public PKCE client
redirectUri: "myapp://auth/callback",
);
await auth.restore(); // pick up an existing session on boot
await auth.signIn(); // kicks off OIDC PKCE
final me = await auth.profile(); // userinfo claims
final res = await auth.client.get( // authed http — bearer added, refresh on 401
Uri.parse("https://pay.imbenji.net/api/v1/entitlements"),
);
await auth.signOut();
auth.isSignedIn; // bool
auth.accessToken; // String? — the opaque bearer
GarageAuth is a ChangeNotifier, so hand it to provider or a
ListenableBuilder and the UI rebuilds on sign in / out. Share one instance
across the app — the other packages take it and reuse its session and client.
Scopes default to openid profile email; pass scopes: for more.
Sign-in has two halves
OIDC needs a round trip to the identity provider, so it's split:
signIn()(same asbeginSignIn()) builds the PKCE challenge, stashes the verifier + state, and sends the user to the authorize endpoint.- Web: the tab navigates away. The future never really "returns" — the app reloads at the redirect path.
- Native / desktop: the system browser opens and the app keeps running.
completeSignIn(params)finishes the token exchange. Feed it the query params off the inbound callback URL. A badstate, a missing code or anerrorparam throws anAuthError.
Wiring the callback on web, with go_router:
GoRoute(
path: "/auth/callback",
builder: (context, state) {
final auth = context.read<GarageAuth>();
auth.completeSignIn(state.uri.queryParameters).then((_) {
if (context.mounted) context.go("/");
});
return const Center(child: CircularProgressIndicator());
},
);
On native, catch the inbound deep link (app_links or similar) and hand it over:
final uri = Uri.parse(incomingDeepLink);
await auth.completeSignIn(uri.queryParameters);
A custom-scheme redirect like myapp://auth/callback has to be registered in
each platform's runner — that's the app's job, a package cant do it for you. See
docs/platform-setup.md.
Tokens
Tokens are opaque, validated server side by introspection. The authed
client refreshes once on a 401 and replays the request; if the refresh
fails the session is dropped so your UI can ask the user to sign in again.
Only replayable (non-streamed) requests get the refresh.
Storage defaults to flutter_secure_storage (SecureTokenStore) — Keychain /
Keystore on mobile, libsecret / DPAPI on desktop, best effort on web. Swap it
through the TokenStore seam:
final auth = GarageAuth(
issuer: ...,
clientId: ...,
redirectUri: ...,
tokenStore: MemoryTokenStore(), // tests, or your own hive/prefs store
);
Keys are namespaced by clientId, so two instances in one app wont collide.
The default store has two sharp edges — unsigned macOS builds and web compiled to wasm. Both are in docs/platform-setup.md.
garage_entitlements
Give it a signed-in GarageAuth, a project slug and the commerce base url. It
fetches a key per entitlement, verifies each one, and answers has(sku) with no
network at all.
import "package:garage_entitlements/garage_entitlements.dart";
final ent = GarageEntitlements(
auth: auth, // a signed-in GarageAuth
projectSlug: "field-notes",
apiBaseUrl: "https://pay.imbenji.net/api",
);
await ent.cached(); // boot: verifies whats on disk
await ent.refresh(); // whenever you have a connection
if (ent.has("pro-annual")) unlockTheThing();
It's a ChangeNotifier, so a ListenableBuilder redraws your gates when the set
moves. Call clear() on sign-out.
cached() needs the user's sub to check keys against, and it gets that from
userinfo — one network call per instance, then it's remembered. On a cold start
with no connection it cant resolve it, and no keys load (the ones on disk are
kept for next time).
One key per entitlement
Money bought one thing, so the key unlocks that one thing. A single project-wide
licence would be a wallet — hand it to a lock that cares about one feature and
it learns everything the user owns. Each key is a compact RS256 JWT whose aud
is "<project>/<sku>", which is what lets a lock verify one knowing only its
own sku and the public key.
The two clocks
final key = ent.key("pro-annual")!;
key.expiresAt; // the KEY's exp — this IS the access decision
key.entitlementExpiresAt; // when the subscription runs out, or null
The server clamps a key's exp to the entitlement's own expiry (and the grace
window on a bouncing renewal) before signing, so a key can never outlive the
thing it unlocks. Checking exp alone is correct. entitlementExpiresAt is how
a UI says "renews on the 3rd" — never gate on it.
Refresh replaces, it doesnt merge
The one behaviour worth knowing. refresh() verifies every key in the
response before writing anything, then replaces the whole cached set for the
project. Anything not in the response is gone.
A cancelled subscription simply stops coming back. If refresh merged, its key
would sit there working untill its own exp. A response that can't be fully
verified leaves the previous set exactly as it was — there's no half-applied
refresh.
Key lifetime
The server decides, from the product's licence_ttl_seconds, then the
project's, then its own default. You can ask for less with
refresh(ttl: ...); asking for more is quietly clamped. Shorter means the app
has to come online more often. Longer means a refunded purchase stays unlocked
that long, because nothing revokes a key before its exp.
Sandbox
A sandbox key never satisfies a live check. While the project is in sandbox,
construct with mode: "sandbox".
The rest
await ent.entitlements(); // the ledger, straight from the server
await ent.productBySku("pro"); // name + price, for a purchase screen
await ent.portalUrl(productId, returnUrl: "myapp://pay/done");
entitlements() is the truth, but it's a network call and says nothing offline
— use it for a purchase screen ("you allready own this"), not a feature gate.
portalUrl() mints a one-shot handoff code so the buyer doesnt sign in twice,
and gives you back the checkout Uri. Launching it is yours — there's no
url_launcher or Stripe dependency in this package, on purpose, so it builds
clean on desktop. The returnUrl must be registered on the project or checkout
refuses it.
What it isn't: a purchase SDK, a replacement for the ledger, or a revocation system. How verification works, check by check, and how the cache is laid out: docs/offline-licences.md.
garage_iap
Built for the app-store storefront, which is parked. It still works and the storefront still uses it, but a new app selling through Garage wants
garage_entitlementsplus the hosted checkout.
import "package:garage_iap/garage_iap.dart";
final iap = GarageIap(
auth: auth,
appSlug: "my-app",
apiBaseUrl: "https://store.imbenji.net/api",
);
final products = await iap.products(); // List<GarageProduct>
final ents = await iap.entitlements(); // List<GarageEntitlement>
await iap.purchase(products.first, mode: PurchaseMode.sheet); // or .handoff
final licence = await iap.licence(); // cached, offline-capable
final pro = iap.has("pro");
Purchase modes
PurchaseMode.handoff— creates a checkout, opens it in the system browser, then polls entitlements until the product is granted. The webhook is the source of truth; the redirect is just a nudge. Works everywhere.PurchaseMode.sheet— the embeddedflutter_stripePaymentSheet for one-time products. Subscriptions, and platforms with no native sheet (web, linux, desktop), fall back to the handoff automatically.
Either way it polls with a backoff afterwards. A grant that hasnt landed by
pollTimeout returns PurchaseOutcome.pending — not an error, it may still
arrive.
The sheet needs flutter_stripe's own platform setup (iOS 13+, an Android
FlutterFragmentActivity, …). On iOS and Android that setup isnt optional —
without it purchase() throws rather than falling back, so use
PurchaseMode.handoff if you cant do it. Web and desktop fall back on their own.
Offline licence
licence() fetches one licence JWT covering the app's products and the JWKS
in the same trip, and caches both (gi.licence.<appSlug>). Offline it checks the
RS256 signature against the cached JWKS by kid, exp against the device
clock, and that app and sub match. No cached licence, or past exp while
offline, means not entitled. cachedLicence() is the no-network boot path;
licence(forceRefresh: true) forces a fetch.
Seams: LicenceCache (MemoryLicenceCache for tests), merchantName for the
sheet, pollTimeout.
garage_ui
The widgets and theme every Garage app is built with.
import "package:garage_ui/garage_ui.dart";
final theme = ThemeData( // garage_ui's ThemeData, not material's
colorScheme: ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xFF18181B),
foreground: const Color(0xFFFAFAFA),
primary: const Color(0xFF3B82F6),
),
density: const Density.compact(), // or .normal() / .product()
);
GarageApp.router(routerConfig: router, theme: theme);
Inside, everything reads the theme:
final theme = GarageTheme.of(context);
final cs = theme.colorScheme; // ColourScheme — every colour
theme.density; // Density — every size
theme.typography;
Two influences, and they arent the same kind:
- shadcn — the API. Variant names (
.primary/.secondary/.outline/.ghost/.destructive), theColourSchemeslot names, the dot-constructor shape. That's why a shadcn snippet usually compiles. - Blender — the rendering. Flat chrome, bordered panels, dense controls, no elevation. That's why the same snippet doesnt look like shadcn once it runs.
There's no package:flutter/material.dart anywhere in it. No Scaffold, no
ThemeData.dark(), no literal Color(0x…) outside the theme files — if your
widget reaches for one, you've stepped outside the system.
The API is in the source (every file in lib/ is short and commented). Which
widget, which variant, which colour token in which situation is the other half,
and that's docs/garage-ui-style-guide.md.
The package also ships a small macOS plugin (GarageUiPlugin) for the
eyedropper and cursor lock.
More docs
- docs/local-development.md — editing the SDKs alongside an app, with hot reload
- docs/platform-setup.md — redirect URIs, macOS Keychain, wasm, Stripe
- docs/offline-licences.md — how a licence key is verified and cached
- docs/garage-ui-style-guide.md — using garage_ui so it actually looks like a Garage app
The HTTP API these packages call is documented on its own, so you can drop to it whenever you need to — the SDKs are a convenience, not a requirement. Selling in particular is three plain HTTP calls and a redirect; the one part you shouldnt hand-roll is verifying a licence key.