Privileged mini-apps
When to reach for `@i99dash/admin-sdk`, how the security model works, and how to write your first privileged call.
You're building a mini-app that needs to run device-side commands —
restart MQTT, disable a stuck Android user, list installed packages,
tail device logs. Regular @i99dash/sdk can't do this; you need
@i99dash/admin-sdk.
This page is the dev-side onboarding journey: the security model in plain language, how to get installed, how to write your first call, how to test locally, and where to look when things go wrong.
Reference docs are at
AdminClientandCommandTemplate.
When to use it (and when not to)
Use @i99dash/sdk | Use @i99dash/admin-sdk |
|---|---|
| Read host context (user, VIN, locale) | Same — admin builds on top |
Call backend APIs (callApi) | Same — admin builds on top |
| Read fuel prices, weather, navigation | When you also need pm.*, sys.*, diag.*, fs.* |
| Public catalog distribution | Restricted distribution; install requires a cmdExec.* permission grant |
If your mini-app only reads context and proxies API calls, stop here — the public SDK already covers you. Only continue if you need device-side privileged ops.
Security model in 60 seconds
The dev-relevant slice:
- Declaration — your
manifest.jsonlists thecmdExec.*permissions your app needs. - Consent — at install time the user sees the list and approves.
- Server cap — the backend mints a 30-day session capability token and hands it to the Flutter host, not to your mini-app.
- Dispatch — at call time your code does
admin.invoke('templateId', params). The host attaches the right cap, validates params, executes the templated command, returns the envelope. Your JS layer never sees a cap token.
Practical consequence: a compromised mini-app bundle has no cap to leak. There is no "auth header" to steal — the cap lives in the host's encrypted SQLite, never crosses into your bundle.
Prerequisites
| Prereq | Where to get it |
|---|---|
cmdExec.* permission grant on your developer account | Request from your i99dash admin. The package itself is gated — without a grant your pnpm install 404s. |
GitHub account with read:packages scope | github.com/settings/tokens — create a classic token, check read:packages |
| All the regular SDK prereqs | See Installation (Node 20+, pnpm) |
Install
@i99dash/admin-sdk lives on GitHub Packages, not public npm.
Two-step install:
1. Configure your registry
In your project's .npmrc (create it if it doesn't exist):
@i99dash:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}Then export the token:
export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxCI: store as an Actions secret and inject the same env var. The
existing secrets.GITHUB_TOKEN works for repos in the i99dev org.
2. Add the package
pnpm add @i99dash/admin-sdk @i99dash/sdk@i99dash/sdk is a peer concern — admin builds on top of the regular
runtime client, so install both.
If pnpm errors with 403 Forbidden, check your token has
read:packages and your account has been granted access to the
package. There is no public fallback.
Your first privileged call
The shape is: get the regular SDK client, then build the admin client
on top of it, then call invoke().
import { MiniAppClient } from '@i99dash/sdk';
import { AdminClient, snapshotFromList } from '@i99dash/admin-sdk';
const sdk = MiniAppClient.fromWindow();
const ctx = await sdk.getContext();
// The host injects the template catalog on the global. In dev,
// fall back to a hardcoded snapshot — see Local development below.
const admin = AdminClient.fromWindow({
context: { appId: ctx.appId, vin: ctx.activeCarId },
catalog: snapshotFromList(window.__i99dashAdminCatalog ?? []),
});
const r = await admin.tailLogs({ lines: 50 });
if (r.success) {
console.log(r.data.lines);
} else {
console.warn(r.error.code, r.error.message);
}Two response paths to wire:
r.success === true— the op ran.r.datais typed per template.r.success === false— the op was rejected at the host.r.error.codeis one of a fixed set. These are not thrown — they're first-class data the user might see in your UI.
Genuine errors (no host bridge, timeout, transport crash, unknown template) do throw. See Error handling below.
Local development
You don't need a real car (or even a backend) to develop a privileged mini-app. Two paths:
Path 1 — sdk-dev-server with a hardcoded catalog
The dev-server doesn't ship a built-in admin catalog. The convention
is to stash a dev catalog in your app and feed it to
snapshotFromList() when window.__i99dashAdminCatalog is missing:
// src/lib/admin.ts
import {
AdminClient,
NotInsideHostError,
snapshotFromList,
type CommandTemplate,
} from '@i99dash/admin-sdk';
const DEV_CATALOG: CommandTemplate[] = [
{
id: 'diag.tail_logs',
permissionId: 'cmdExec.read',
tier: 1,
requiresStepUp: false,
category: 'diagnostics',
paramSchema: { lines: { type: 'int', min: 1, max: 1000 } },
},
// …mirror the templates your app uses
];
export function getAdmin(): AdminClient | null {
try {
return AdminClient.fromWindow({
context: { appId: 'my-privileged-app', vin: 'pending' },
catalog: snapshotFromList(window.__i99dashAdminCatalog ?? DEV_CATALOG),
});
} catch (err) {
if (err instanceof NotInsideHostError) return null;
throw err;
}
}Path 2 — FakeAdminBridge (unit tests, Storybook, SSR)
For tests, skip the host bridge entirely:
import { AdminClient, FakeAdminBridge, snapshotFromList } from '@i99dash/admin-sdk';
const admin = AdminClient.withBridge({
bridge: new FakeAdminBridge(async (req) => ({
success: true,
data: { lines: ['line one', 'line two'] },
})),
context: { appId: 'test-app', vin: 'TEST-VIN' },
catalog: snapshotFromList([
{
id: 'diag.tail_logs',
permissionId: 'cmdExec.read',
tier: 1,
requiresStepUp: false,
category: 'diagnostics',
paramSchema: { lines: { type: 'int', min: 1, max: 1000 } },
},
]),
});
await admin.tailLogs({ lines: 5 }); // hits your fake handlerThe fake handler receives the wire shape ({templateId, params, idempotencyKey}) — assert on it directly.
Error handling
There are two failure surfaces. Treat them differently.
Thrown — programmer / environment issues
| Class | Meaning | Caller responsibility |
|---|---|---|
NotInsideHostError | No window.__i99dashHost — SSR, Node, Storybook, or running outside the i99dash host | Catch and render a fallback UI |
UnknownTemplateError | templateId not in your catalog snapshot | Bug — your catalog is stale or wrong |
BridgeTimeoutError | Host bridge didn't respond within timeoutMs (default 10s) | Retry with backoff, or surface "device unresponsive" |
BridgeTransportError | Bridge layer crashed — host has a bug | Log and report; usually transient |
import {
BridgeTimeoutError,
BridgeTransportError,
NotInsideHostError,
UnknownTemplateError,
} from '@i99dash/admin-sdk';
try {
const r = await admin.tailLogs({ lines: 50 });
// …
} catch (err) {
if (err instanceof BridgeTimeoutError) {
/* retry */
} else if (err instanceof NotInsideHostError) {
/* fallback */
} else throw err;
}Returned in envelope — operational rejections
{success: false, error: {code, message}} is data, not an
exception. The ones you'll handle most:
| Code | What to render |
|---|---|
user_consent_missing | "This action needs permission. Open Settings → Mini-apps to grant." |
step_up_required | (Host already prompted; this means the user denied step-up.) "Action cancelled." |
param_validation_failed | Bug in your code — the params didn't match the template's param_schema |
cert_revoked | Show a hard-fail banner — your dev cert was revoked |
session_cap_expired | Transient — the host should auto-refresh; retry once |
Manifest declaration
Your manifest.json must list the cmdExec.* permissions your app
needs. Without this, the user never sees the consent prompt and your
calls get user_consent_missing envelopes forever.
{
"id": "my-privileged-app",
"permissions": ["cmdExec.read", "cmdExec.control"]
}| Permission | Grants |
|---|---|
cmdExec.read | All tier-1 templates (diag.tail_logs, pm.list_*, fs.ls, diag.mqtt_status) |
cmdExec.control | All tier-2 templates (pm.disable_user, diag.restart_mqtt, sys.reboot, pm.install) |
Step-up templates (sys.reboot, pm.install) still require
cmdExec.control in the manifest and trigger a fresh user prompt
at action time.
Publishing
Same flow as a regular mini-app — sdk-i99dash publish builds, uploads,
and submits. The catalog gates visibility on the consuming user's
device cert, not on the developer side; you publish exactly like a
public app and the host filters who sees it.
See Upload your app for the publish flow end-to-end.
Flight test
The pre-flight checklist before promoting a beta build to production. Verifies the build holds up on real cars, not just your dev-server.
PII scopes (location, navigation)
How to apply for PII-tier permission scopes (location.read, nav.read) through the developer portal. Why there's no per-user runtime prompt.