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:
| Setting | What the person does | What your render reads |
|---|---|---|
account | Signs in to the service and allows access | An access token, from ctx.auth.getToken |
credential | Types in a token or key the service gave them | That 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
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:
platform | Service | Access |
|---|---|---|
github | GitHub | read:user repo |
todoist | Todoist | data:read |
youtube | YouTube | https://www.googleapis.com/auth/youtube.readonly |
spotify | Spotify | user-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
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.
How people link an account
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:
ledable auth-hub link github
ledable store install my-repos --device 1234 --values 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.
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
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:
ledable store install gitlab-todos --device 1234 --credential tokenExample
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.

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.
{
"accounts": { "my-github": "a GitHub token of your own" },
"credentials": { "my-gitlab": "a GitLab token of your own" }
}ledable developer set --set token=my-gitlabAn 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.