# Garage SDKs Flutter packages for building apps against [Garage](https://hub.imbenji.net) — sign in, offline licence checks, in-app purchasing, and the design system every Garage app is drawn with. | Package | What it does | | --- | --- | | [`garage_auth`](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`](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`](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`](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](LICENSE). - [Install](#install) - [Hosts](#hosts) - [garage_auth](#garage_auth) - [garage_entitlements](#garage_entitlements) - [garage_iap](#garage_iap) - [garage_ui](#garage_ui) - [More docs](#more-docs) ## Install The packages arent on pub.dev. Git-depend on this repo, pick the package with `path:`, and pin a tag: ```yaml dependencies: garage_auth: git: url: https://git.imbenji.dev/IMBENJI.NET/Garage-SDKs.git path: garage_auth ref: v0.1.0 garage_entitlements: git: url: https://git.imbenji.dev/IMBENJI.NET/Garage-SDKs.git path: garage_entitlements ref: v0.1.0 ``` Pin a tag, dont float `main`. `garage_entitlements` decides who gets the paid features, and "it changed under us" is not a fun thing to debug. `garage_entitlements` and `garage_iap` layer on `garage_auth`. You get it transitively, but declare it too — you'll be constructing a `GarageAuth` yourself anyway, and keep all of them on the same `ref`. **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](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 ```dart 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: 1. **`signIn()`** (same as `beginSignIn()`) 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. 2. **`completeSignIn(params)`** finishes the token exchange. Feed it the query params off the inbound callback URL. A bad `state`, a missing code or an `error` param throws an `AuthError`. Wiring the callback on web, with `go_router`: ```dart GoRoute( path: "/auth/callback", builder: (context, state) { final auth = context.read(); 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: ```dart 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](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: ```dart 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](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. ```dart 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 `"/"`, which is what lets a lock verify one knowing only its own sku and the public key. ### The two clocks ```dart 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 ```dart 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](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_entitlements` plus the hosted checkout. ```dart 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 final ents = await iap.entitlements(); // List 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 embedded `flutter_stripe` PaymentSheet 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.`). 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. ```dart 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: ```dart 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`), the `ColourScheme` slot 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](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](docs/local-development.md) — editing the SDKs alongside an app, with hot reload - [docs/platform-setup.md](docs/platform-setup.md) — redirect URIs, macOS Keychain, wasm, Stripe - [docs/offline-licences.md](docs/offline-licences.md) — how a licence key is verified and cached - [docs/garage-ui-style-guide.md](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.