@quickflo/app-sdk
v0.7.2
Published
Framework-agnostic browser SDK for QuickFlo micro-apps: identity-aware onboarding, entitlement checks, and typed webhook + form trigger clients.
Maintainers
Readme
@quickflo/app-sdk
Framework-agnostic browser SDK for QuickFlo micro-apps.
Centralizes the three things every QF-backed app re-implements from scratch:
- identity-aware onboarding (
POST /api/onboarding/apps/:appId/install) - per-app entitlement reads + paywall guard helpers
- typed webhook and form trigger clients (
/w/@:orgSuid/:name,/f/@:orgSuid/:name/*)
No Vue, React, Astro, or Quasar coupling. The SDK uses fetch and optional browser storage (sessionStorage / localStorage); falls back to in-memory storage in SSR / Node.
Install
pnpm add @quickflo/app-sdk zodzod is a peer dependency. @quickflo/entitlement-schema is bundled and re-exported.
Quick start
import { createQuickFloClient } from '@quickflo/app-sdk';
import { supabase } from './supabase';
const qf = createQuickFloClient({
appId: 'list-scrub',
// Resolver MUST return null when no session is active.
getAuthToken: async () => {
const { data } = await supabase.auth.getSession();
return data.session?.access_token ?? null;
},
onUnauthenticated: () => {
window.location.href = '/login';
},
onPaywall: () => {
window.location.href = '/register?paywall=1';
},
});Return pattern
Every HTTP method on the client returns { data, error, response }. No exceptions,
no try/catch:
const { data, error, response } = await qf.ensureOnboarded();
if (error) {
// error.code → 'network' | 'http_4xx' | 'http_5xx' | 'parse' | 'auth_missing' | 'aborted' | 'validation' | 'inactive_entitlement'
// error.status → HTTP status when applicable, else null
// error.body → server-provided error payload when available
// response → raw Response when the request reached the server
console.error(error.message);
return;
}
// data is non-null here
console.log(data.entitlement.tier);Sync helpers (cache reads, hash parsers) return their values directly : only the methods that hit the network use the result shape.
Onboarding + entitlement
// Idempotent: caches the result in sessionStorage by default.
const { data, error } = await qf.ensureOnboarded();
if (data) console.log(data.orgId, data.orgSuid, data.entitlement);
// Page-guard for paywalled routes. Fires onUnauthenticated / onPaywall as a
// side effect and surfaces the same condition via error.code.
const guard = await qf.requireActiveEntitlement();
if (guard.error?.code === 'inactive_entitlement') {
// onPaywall has already fired; render the upgrade state here too if needed.
}
// Synchronous reads for inline UI gating.
const cached = qf.getCachedEntitlement();
qf.clearCache();ensureOnboarded({ force: true }) skips the cache.
Org scoping
Trigger calls target an org by its orgSuid. You don't pass it explicitly:
after ensureOnboarded(), the SDK caches the onboarding result and uses its
orgSuid as the default for every forms.* / webhooks.* call. Pass
{ orgSuid } in the options only to override (e.g. credentials-flow forms for
users who aren't onboarded QF identities). A call made before ensureOnboarded()
with no explicit orgSuid returns a validation error.
Webhook triggers
const { data, error } = await qf.webhooks.run('start-scan', {
url: 'https://example.com',
});
if (error) return;
console.log(data.status, data.body);Auth modes for webhooks
Webhook trigger configs declare an authType, but there's no public probe
endpoint to discover it from the browser. Callers pass authType per call:
// 'qf-identity' (default): SDK attaches getAuthToken() as a Bearer.
await qf.webhooks.run('private-hook', body);
// 'token': caller passes the static secret. The token is visible to anyone
// who can read your bundle : only use this for non-browser callers or for
// genuinely public webhooks where leakage is acceptable.
await qf.webhooks.run('public-hook', body, {
authType: 'token',
token: process.env['PUBLIC_QF_AUTH_HOOK'],
});
// 'none': no Authorization header.
await qf.webhooks.run('open-hook', body, { authType: 'none' });
// Override the target org explicitly:
await qf.webhooks.run('private-hook', body, { orgSuid: 'otherOrg' });Typed seam (bd-xh7a codegen target)
type ScanIn = { url: string };
type ScanOut = { scanId: string };
const { data } = await qf.webhooks.runTyped<ScanIn, ScanOut>('start-scan', {
url: 'https://example.com',
});Form triggers
The full structured form surface (org defaults to the onboarding result):
// 1. Schema + auth metadata
const { data: schema } = await qf.forms.getFormSchema('start-scan');
// → { schema, formMeta, auth: { provider, ... }, hasPrefill, hasConfirmation, ... }
// 2. Prefill (optional, when hasPrefill)
const { data: prefill } = await qf.forms.getFormPrefill('start-scan');
// prefill?.warning carries X-Prefill-Warning when the backend fails open.
// 3. File upload (signed-URL handshake, direct browser → GCS, no proxy)
const { data: uploaded } = await qf.forms.uploadFormFile('start-scan', file);
// 4. Confirmation (optional, when hasConfirmation)
const { data: confirmation } = await qf.forms.getFormConfirmation('start-scan', {
agents: [1, 2, 3],
});
// confirmation?.warning carries X-Confirmation-Warning when fail-open.
// 5. Submit
const { data: submitted } = await qf.forms.submitForm('start-scan', {
url: 'https://example.com',
upload: uploaded,
});
if (submitted && !submitted.success) {
console.error(submitted.message, submitted.supportReference);
}Auth modes for forms
Forms expose their auth provider via getFormSchema().auth.provider. The
SDK caches that value per (orgSuid, name) and uses it to decide how to
authenticate subsequent calls:
'none': no Authorization header.'qf-identity': SDK attaches the current QF identity token fromgetAuthToken. This is the default for SDK-driven micro-apps and what closes the "second login" friction.'credentials': separate session-token flow for external-user forms. The SDK provides four endpoints + a hash-parsing helper. These forms are often for users who aren't onboarded QF identities, so pass{ orgSuid }explicitly:
// Password
const { data: session } = await qf.forms.submitFormCredentials(
'name',
{ username: '[email protected]', password: 'hunter2' },
{ orgSuid: 'abc123' },
);
// Magic link request (always resolves { ok: true } regardless of match)
await qf.forms.requestMagicLink('name', { email: '[email protected]' }, { orgSuid: 'abc123' });
// Magic link redemption : the backend redirects to
// <formUrl>#auth_token=<jwt>&username=<email>
// Parse that fragment with parseMagicLinkHash, then remember the token:
const parsed = qf.forms.parseMagicLinkHash(window.location.hash);
if (parsed) {
qf.forms.rememberFormAuthToken('name', parsed.authToken, { orgSuid: 'abc123' });
}
// OTP
await qf.forms.requestOtp('name', { email: '[email protected]' }, { orgSuid: 'abc123' });
const { data: otpSession } = await qf.forms.verifyOtp(
'name',
{ email: '[email protected]', code: '123456' },
{ orgSuid: 'abc123' },
);The credentials-flow session token is cached per (orgSuid, name) in the
configured storage and attached to every subsequent form-scoped call.
Typed seam
type SubmitIn = { url: string };
type SubmitOut = { success: true; executionId: string };
const { data } = await qf.forms.submitFormTyped<SubmitIn, SubmitOut>('start-scan', {
url: 'https://example.com',
});Billing
qf.billing wraps the centralized platform Stripe workflows. Subscription
billing with zero per-app billing code — the app calls two methods; the
platform org owns the Stripe secret and the customer→org mapping.
// Start a subscription Checkout Session, then redirect.
const { data } = await qf.billing.checkout({ tier: 'pro' });
if (data?.url) window.location.href = data.url;
// Manage / cancel via the Stripe billing portal.
const { data: portal } = await qf.billing.billingPortal();
if (portal?.url) window.location.href = portal.url;Address the price by tier (+ optional interval, default 'month', resolved
via the stripe-sync lookup key <appId>_<tier>_<interval>) or by an explicit
priceId (from VITE_QF_PRICE_*):
await qf.billing.checkout({ priceId: 'price_123' });
await qf.billing.checkout({ tier: 'pro', interval: 'year' });
await qf.billing.checkout({ tier: 'pro', metadata: { campaign: 'launch' } });What the SDK fills in for you:
organizationId— the user's own org, auto-filled from cachedensureOnboarded()(override via{ organizationId }). The platform workflow validates it against the caller's token, so checkout requires onboarding; with no cached org it returns avalidationerror rather than firing.- Routing — every call targets the platform org
(
config.platformOrgSuid, default'platform'), not the user's org. The user's org rides in the body. Override the routed org per call with{ orgSuid }(for future user-owned billing entrypoints). - Redirect URLs —
successUrl/cancelUrl/returnUrldefault towindow.location.href.
// Point the client at a non-default platform org (dev/staging):
createQuickFloClient({ appId, getAuthToken, platformOrgSuid: 'platform-staging' });Subscription status is not a separate billing call — it's the entitlement,
already exposed by ensureOnboarded() / requireActiveEntitlement().
Cache + storage
Default cache keys:
${appId}_onboarding_v1for the onboarding result${appId}_form_auth_v1:<orgSuid>:<name>for credentials-flow session tokens
Override either via cache.onboardingKey / cache.formAuthKeyPrefix.
Default backend is sessionStorage in a browser, in-memory otherwise.
Override via cache.storage:
import { createMemoryStorage } from '@quickflo/app-sdk';
createQuickFloClient({
// ...
cache: { storage: 'local' }, // localStorage
// or
cache: { storage: createMemoryStorage() }, // explicit memory
// or
cache: { storage: myRedisBackedAdapter }, // custom { getItem, setItem, removeItem }
});Error handling
Every HTTP method's result has the same shape:
interface QuickFloResult<T> {
data: T | null;
error: QuickFloError | null;
response: Response | null;
}
interface QuickFloError {
message: string;
code: QuickFloErrorCode;
status: number | null;
body?: unknown;
}
type QuickFloErrorCode =
| 'network' // fetch threw: connection lost, DNS, CORS, etc.
| 'http_4xx' // server returned 4xx
| 'http_5xx' // server returned 5xx
| 'parse' // response body could not be parsed
| 'auth_missing' // getAuthToken returned null when auth was required
| 'inactive_entitlement' // requireActiveEntitlement: signed in but no active entitlement
| 'aborted' // request was aborted via signal
| 'validation'; // caller passed invalid arguments before any request firedForm submissions are a special case: submitForm returns the
{ success: false, message, supportReference, errors? } payload as data
even when the backend returned a 4xx. Workflow validation failures
shouldn't force consumers to branch on HTTP status : the structured body
is the contract. Only network failures and non-structured non-2xx
responses come back as error.
unwrap — throw instead of branch
The result pattern is the default, but for call sites that prefer a throw,
unwrap(result) returns data or throws a QuickFloThrownError carrying the
code / status / body / response:
import { unwrap, QuickFloThrownError } from '@quickflo/app-sdk';
try {
const { url } = unwrap(await qf.billing.checkout({ tier: 'pro' }));
window.location.href = url;
} catch (err) {
if (err instanceof QuickFloThrownError) console.error(err.code, err.status);
}License
Apache-2.0
