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:
@@ -0,0 +1,383 @@
|
||||
# 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<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.
|
||||
Reference in New Issue
Block a user