Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
390 lines
15 KiB
Markdown
390 lines
15 KiB
Markdown
# 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 the commit of a release tag:
|
|
|
|
```yaml
|
|
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](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<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:
|
|
|
|
```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 `"<project>/<sku>"`, 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<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 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.<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.
|
|
|
|
```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.
|