i99dash docs
Develop

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 AdminClient and CommandTemplate.

When to use it (and when not to)

Use @i99dash/sdkUse @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, navigationWhen you also need pm.*, sys.*, diag.*, fs.*
Public catalog distributionRestricted 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:

  1. Declaration — your manifest.json lists the cmdExec.* permissions your app needs.
  2. Consent — at install time the user sees the list and approves.
  3. Server cap — the backend mints a 30-day session capability token and hands it to the Flutter host, not to your mini-app.
  4. 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

PrereqWhere to get it
cmdExec.* permission grant on your developer accountRequest from your i99dash admin. The package itself is gated — without a grant your pnpm install 404s.
GitHub account with read:packages scopegithub.com/settings/tokens — create a classic token, check read:packages
All the regular SDK prereqsSee 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_xxxxxxxxxxxxxxxxxxxxxxxx

CI: 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.data is typed per template.
  • r.success === false — the op was rejected at the host. r.error.code is 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 handler

The 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

ClassMeaningCaller responsibility
NotInsideHostErrorNo window.__i99dashHost — SSR, Node, Storybook, or running outside the i99dash hostCatch and render a fallback UI
UnknownTemplateErrortemplateId not in your catalog snapshotBug — your catalog is stale or wrong
BridgeTimeoutErrorHost bridge didn't respond within timeoutMs (default 10s)Retry with backoff, or surface "device unresponsive"
BridgeTransportErrorBridge layer crashed — host has a bugLog 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:

CodeWhat 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_failedBug in your code — the params didn't match the template's param_schema
cert_revokedShow a hard-fail banner — your dev cert was revoked
session_cap_expiredTransient — 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"]
}
PermissionGrants
cmdExec.readAll tier-1 templates (diag.tail_logs, pm.list_*, fs.ls, diag.mqtt_status)
cmdExec.controlAll 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.

On this page