ImBenjiandClaude Opus 5.5 b5e5ed5b75 Add a design system for Claude Design, generated from garage_ui
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
2026-09-23 23:19:02 +01:00
2026-09-23 18:49:21 +01:00
2026-09-23 18:49:21 +01:00
2026-09-23 18:49:21 +01:00
2026-09-23 18:49:21 +01:00

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:

  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:

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_entitlements plus 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 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.

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), 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.

The package also ships a small macOS plugin (GarageUiPlugin) for the eyedropper and cursor lock.

More docs

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.

S
Description
No description provided
Readme MIT
423 KiB
Languages
Dart 83.5%
HTML 12%
CSS 3.7%
Swift 0.7%
Ruby 0.1%