Commands, settings, SDK functions, anything in the docs.

Build apps

Accounts and credentials

Let people connect their own accounts and tokens, and use them safely.

On this page

Some apps show a person's own data: their repositories, their to-do list, the song they are playing. Two kinds of setting let people give your app access to it, without their token or key ever being stored in the playlist or reaching the display:

SettingWhat the person doesWhat your render reads
accountSigns in to the service and allows accessAn access token, from ctx.auth.getToken
credentialTypes in a token or key the service gave themThat value, from ctx.credentials.get

In both cases the setting holds only a handle: a reference to what LEDABLE keeps for the person. Your render receives the handle in params and trades it for the token or the value when it needs one.

Accounts

Declare an account setting

TSX
import { account, defineApp } from "@ledable/sdk/app";

export default defineApp({
  settings: () => ({
    github: account({
      platform: "github",
      name: "GitHub",
      description: "The account whose repositories are shown",
      required: true,
    }),
  }),
  // render and preview as in any app
});

platform names the service. These are the services people can link, and what the tokens they get may do:

platformServiceAccess
githubGitHubread:user repo
todoistTodoistdata:read
youtubeYouTubehttps://www.googleapis.com/auth/youtube.readonly
spotifySpotifyuser-read-currently-playing

LEDABLE sets up these services and the access they ask for. You cannot add a service or ask for more access from your app. If the service you need gives people personal tokens or API keys, use a credential setting instead.

Your app calls the service's API itself, so its host goes in network.allowed_hosts, for example api.github.com.

Read the token

TSX
const token = await ctx.auth.getToken(params.github);
if (token === null) {
  return { ok: false, msg: "Link your GitHub account again" };
}
const response = await fetch("https://api.github.com/user/repos", {
  headers: { Authorization: `Bearer ${token}` },
});

getToken answers a token that is valid now; LEDABLE renews it with the service when it expires. It answers null when the person has to link the account again, for example after they revoked the access at the service. When the service cannot be reached for the moment, getToken throws: the access may still be fine, so let the render fail and the display tries again. The snippet reports a failure for null to stay short; drawing a short notice, as the example below does for a missing token, tells the person what to do.

An account setting that is not required is null in params until the person links an account, and TypeScript makes you check for that before you call getToken.

The default key of ctx.cache includes every setting, the handle among them, so data cached for one person's account is never served to another.

In the LEDABLE phone app, adding your app or changing its settings asks the person to link an account of that service, or to pick one they linked before; linking opens the service's sign-in page in the browser.

With the CLI, the person links the account with auth-hub link, which prints its handle as handle_token, and gives that handle as the setting's value when they install the app with store install --values:

Terminal
ledable auth-hub link github
ledable store install my-repos --device 1234 --values values.json
values.json
{ "github": "the handle_token that auth-hub link printed" }

Removing a linked account in the phone app or with auth-hub remove only takes it off the person's list: apps already set up with it keep it until the person revokes the access at the service.

Credentials

A credential setting is for a service that hands out personal access tokens or API keys. The person types the value in once; LEDABLE asks your app whether it works, keeps it encrypted if it does, and your render reads it back by its handle.

Declare a credential setting and its check

An app with a credential setting must export verifyCredential. TypeScript requires it once settings returns a credential, and an upload without one fails its credentials check.

gitlab-todos/index.tsx
  settings: () => ({
    token: credential({
      name: "Personal access token",
      description: "A GitLab token with the read_api scope",
      required: true,
    }),
  }),

  // Runs before the token is kept: only what GitLab accepts is stored.
  async verifyCredential(_ctx, { value }) {
    const response = await todos(value, 1);
    if (response.status === 401) {
      return { valid: false, message: "GitLab does not accept this token." };
    }
    if (response.status === 403) {
      return { valid: false, message: "Give the token the read_api scope." };
    }
    // Any other failure says nothing about the token; throwing tells the
    // person to try again later instead of refusing it.
    if (!response.ok) throw new Error(`GitLab answered ${response.status}`);
    return { valid: true };
  },

verifyCredential runs before anything is kept, each time a person enters a value:

  • Return { valid: true } to accept it.
  • Return { valid: false, message } to refuse it. The person sees your message with the setting's name, so say what to fix. The value and your secrets are replaced with *** in it.
  • Throw when you cannot tell now, for example because the service is down. Nothing is kept, and the person is asked to try again later.

It runs on LEDABLE's servers with the published version's code, under the same rules as a render: only the hosts your app declares, within 10 seconds. Its ctx holds now and your secrets, and no cache. Check what your render needs, not only that the value is well formed: the example above asks GitLab for the very list it shows.

Read the value

TSX
const token = await ctx.credentials.get(params.token);

get answers null when there is no value for the handle any more: the person replaced or removed it, the display left their account, or their LEDABLE account was deleted. A required setting does not change this, so always handle null, preferably by drawing what the person should do.

What is kept, and what is not

  • The value is kept encrypted by LEDABLE, for the app it was entered for. Another app that is given the same handle reads null.
  • It never enters the playlist, the address a display asks for its image, or the display. The phone app and the CLI never get it back after it is saved.
  • A value your check refused, or could not check, is not kept.
  • A value holds at most 4096 characters.

Your app must not store the value anywhere either: do not put it in a message, a cache tag or what you draw.

How people enter a credential

In the phone app, the person types the value into the setting when they add your app or change its settings; it is checked when they save. With the CLI, they name the setting with --credential on store install or playlist configure, and type the value at a hidden prompt or pipe it in as one line of stdin:

Terminal
ledable store install gitlab-todos --device 1234 --credential token

Example

This app shows how many GitLab to-dos are waiting for the person whose token it is given. Its check asks GitLab for the same list it draws, so a token without the right scope is refused with a message that says which scope to add.

The gitlab-todos example on a LEDABLE panel
gitlab-todos/index.tsx
import * as z from "zod/mini";
import { credential, defineApp } from "@ledable/sdk/app";
import {
  Frame, Text, renderFrame, type BitmapFont,
} from "@ledable/sdk/graphics";
import { HOMEVIDEO, TINY, loadFonts } from "@ledable/sdk/fonts";

const API = "https://gitlab.com/api/v4";
const FONTS = { count: HOMEVIDEO, label: TINY } as const;
// GitLab answers at most 100 to-dos a page; a full page means "100 or more".
const PAGE = 100;

type Fonts = { readonly [K in keyof typeof FONTS]: BitmapFont };

const todosSchema = z.array(z.object({ id: z.number() }));

function todos(token: string, perPage: number) {
  return fetch(`${API}/todos?state=pending&per_page=${perPage}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
}

function face(fonts: Fonts, big: string, label: string) {
  return renderFrame(
    <Frame width={64} height={32} background="#000000">
      <Text x={32} y={12} anchor="mm" text={big} font={fonts.count}
        color="#ffffff" />
      <Text x={32} y={26} anchor="mm" text={label} font={fonts.label}
        color="#fc6d26" />
    </Frame>,
  );
}

export default defineApp({
  settings: () => ({
    token: credential({
      name: "Personal access token",
      description: "A GitLab token with the read_api scope",
      required: true,
    }),
  }),

  // Runs before the token is kept: only what GitLab accepts is stored.
  async verifyCredential(_ctx, { value }) {
    const response = await todos(value, 1);
    if (response.status === 401) {
      return { valid: false, message: "GitLab does not accept this token." };
    }
    if (response.status === 403) {
      return { valid: false, message: "Give the token the read_api scope." };
    }
    // Any other failure says nothing about the token; throwing tells the
    // person to try again later instead of refusing it.
    if (!response.ok) throw new Error(`GitLab answered ${response.status}`);
    return { valid: true };
  },

  async render(ctx, params) {
    const fonts = await loadFonts(ctx.assets, FONTS);
    // The setting holds a handle; the token itself is read here, and is
    // null once the person removed it or the display left their account.
    const token = await ctx.credentials.get(params.token);
    if (token === null) return { image: [face(fonts, "?", "ADD A TOKEN")] };
    const response = await todos(token, PAGE);
    // Revoked at GitLab since it was checked: tell the person, not a log.
    if (response.status === 401) {
      return { image: [face(fonts, "!", "TOKEN REVOKED")] };
    }
    if (!response.ok) {
      return { ok: false, msg: `GitLab answered ${response.status}` };
    }
    const count = z.parse(todosSchema, await response.json()).length;
    const big = count >= PAGE ? `${PAGE}+` : String(count);
    return { image: [face(fonts, big, "GITLAB TO-DOS")], softTtlMs: 300_000 };
  },

  async preview(ctx) {
    const fonts = await loadFonts(ctx.assets, FONTS);
    return { image: [face(fonts, "7", "GITLAB TO-DOS")] };
  },
});

In the local preview

The local preview does not reach LEDABLE's servers, so it trades handles for tokens and values from ledable.dev.json. Make up a handle, map it to a token of your own, and give the setting that handle in the preview's form, with developer set, or in the --values file of developer render.

ledable.dev.json
{
  "accounts": { "my-github": "a GitHub token of your own" },
  "credentials": { "my-gitlab": "a GitLab token of your own" }
}
Terminal
ledable developer set --set token=my-gitlab

An account handle the file does not have fails the render with a message saying so. A credential handle the file does not have reads as null, as a removed credential does on LEDABLE's servers, so you can see what your app draws then. The preview does not run verifyCredential; cover it in your tests. More on the file in Local preview.