Files
Garage-SDKs/README.md
T

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.