@slytrunk/sly-tifo-client
v0.4.0
Published
Typed client for the Sly Tifo loyalty API.
Readme
@slytrunk/sly-tifo-client
A typed client for the Sly Tifo loyalty API: standing, awards, and the collectibles/collections/actions catalog.
Install
npm install @slytrunk/sly-tifo-clientUsing React? @slytrunk/sly-tifo-react is released alongside this package with
the same version number, so install matching versions of the two.
Quick start
The client never sees your secret key. It only holds a short-lived end-user
token, minted by your own server and handed to the client through getToken.
Your secret key stays on your server; it never reaches this package, the
browser, or anything you ship to a customer.
import { API_BASES, createClient } from '@slytrunk/sly-tifo-client'
const client = createClient({
apiBase: API_BASES.production,
async getToken() {
// Call your own server, which holds the secret key and mints a
// short-lived token for this end user. This package never sees that key.
// See "Minting a token on your server" below for that route.
const res = await fetch('/api/sly-tifo-token')
return res.json() // { token, expires_at }, exactly as the API sent it
},
})
const standing = await client.me()apiBase accepts a trailing slash. Use API_BASES.stage while testing against
your stage environment.
Minting a token on your server
getToken needs a route on your own server. That route calls
POST /v1/end-user-tokens with your secret key and your own identifier for the
signed-in user, and hands the token to the browser.
There is no separate "create user" call. The first time the API sees an
external_id, it enrolls that user in your program. After that, the same
external_id always means the same user.
// On YOUR server. The secret key must never reach a browser.
const API_BASE = 'https://api.slytifo.com' // or https://api.stage-slytifo.com
app.post('/api/sly-tifo-token', requireSignedIn, async (req, res) => {
const upstream = await fetch(`${API_BASE}/v1/end-user-tokens`, {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.SLY_TIFO_SECRET_KEY}`,
'content-type': 'application/json',
},
// Take the id from YOUR session, never from the request. A route that
// mints for whatever id the browser sends lets anyone act as anyone.
body: JSON.stringify({ external_id: req.user.id }),
})
if (!upstream.ok) {
// Log the details; don't pass them through to the browser.
console.error('sly-tifo token mint failed', upstream.status, await upstream.text())
return res.status(502).json({ error: 'token-unavailable' })
}
// `{ token, expires_at }` is exactly what `getToken` returns, so pass it on.
res.json(await upstream.json())
})Things that go wrong here:
- Keep
expires_atas it is. The client reads that field. If it goes missing, nothing errors, but the client treats every token as already expired and asks for a new one before every request. (expiresAt, the spelling in 0.1.x, still works but is deprecated.) - Build the headers yourself. Don't forward the browser's request headers.
A secret key sent along with the browser's
Originheader is refused with403 secret-key-from-browser. - Set allowed origins on the key first. A key with none can't mint tokens
and gets
422 no-allowed-origins. Set them on the API keys screen. - Match the key to the environment. A stage key mints tokens only stage accepts; see Troubleshooting.
Tokens last fifteen minutes, and the client calls getToken again on its own
when one is about to expire. So this route runs many times per visit. Keep it
to minting only.
Setting a user's name and attributes
Names and attributes, such as a favorite player or a membership number, are set
from your server with PATCH /v1/end-users/{externalId}, using the same secret
key. A browser token can't set them, so users can't rename themselves or pick
their own picture.
The call is the same for new and existing users. If the external_id hasn't
been seen yet, the PATCH enrolls the user first. So you can run it at sign-up,
and again whenever the data changes in your own system:
// On YOUR server.
async function syncSlyTifoUser(user) {
const upstream = await fetch(`${API_BASE}/v1/end-users/${encodeURIComponent(user.id)}`, {
method: 'PATCH',
headers: {
authorization: `Bearer ${process.env.SLY_TIFO_SECRET_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({
display_name: user.name,
attributes: {
favorite_player: user.favoritePlayer, // a declared string attribute
member_since: user.memberSinceYear, // a declared number attribute
},
}),
})
if (!upstream.ok) {
// 422 bodies are { error, message }, and the message names the field.
throw new Error(`sly-tifo sync failed: ${upstream.status} ${await upstream.text()}`)
}
}How the fields behave:
- Only what you send changes. Attributes you leave out keep their values,
and leaving out
display_namekeeps the name. nullclears an attribute.{ "attributes": { "favorite_player": null } }removes that attribute.display_namecan't be cleared, sonullor a blank string there is refused.- Declare attributes before writing them. Each attribute has to be declared
on the program first, under Attributes on the program's home page in admin,
and the value has to match the declared type (string, number, boolean or
image URL). Anything else is refused with
422 invalid-payload, and the message names the attribute. Nothing is dropped silently, and a refused request doesn't enroll anyone. Every program starts with two image attributes already declared:avatarandbackground. - A new user's name defaults to their
external_id. The token route also acceptsdisplay_name, but only uses it when it enrolls someone new, and ignores it for a user who already exists. To set or change a name reliably, use this PATCH.
A typical sign-up runs syncSlyTifoUser first and mints the token afterward.
That way the user already has their name and attributes the first time the
browser asks for their standing.
The methods
client.me()— the signed-in end user's standing (tier, progress, collectibles).options.sets[set].optionslists each unlocked option'snameandart_url/art_thumb_url, as this user should see them.client.award({ action, payload?, subject?, idempotencyKey? })— record an event and get back the tier and collectible changes it caused.client.collectibles()— the collectible catalog.client.collections()— the collection catalog.client.actions()— the action catalog.client.attributes()— the program's declared attributes: each one's key, type and label, an enum'svalues, and an option attribute'sset. Build forms and pickers from it.client.optionSets()— the option set catalog: each set's options, their art and the tier or collectible that gates them.client.optionSet(key)— one option set. An unknown key rejects with anApiErrorwhosestatusis 404.
Refusals
Some perfectly ordinary outcomes — a daily cap already spent, a season that
hasn't opened yet, a ladder with no tier to award — come back as an HTTP 200,
not an error. me() and award() return either the real value or a typed
Refused, so a refusal can't be mistaken for success just because nothing
threw.
import { isRefused } from '@slytrunk/sly-tifo-client'
const result = await client.award({ action: 'watch-game' })
if (isRefused(result)) {
console.log(`refused: ${result.code}`, result.detail)
} else {
console.log('awarded', result)
}A Refused already carries code and detail, split out of reason, so
branch on result.code. splitRefusal(reason) does the same split on a raw
reason string — reach for it only if you got one some other way.
Errors
Anything that isn't a refusal throws an ApiError, with status and code.
A status of 0 and code: 'unreachable' means the request never reached
the server at all (no connection, DNS failure, and so on) — there was no HTTP
response to read a status from.
import { ApiError } from '@slytrunk/sly-tifo-client'
try {
await client.me()
} catch (error) {
if (error instanceof ApiError) {
console.error(error.status, error.code, error.message)
}
}Troubleshooting: a 401 that won't go away
The end-user token you pass in is minted with your secret key, and the key
decides which environment the token is good for — production or stage. If
apiBase points at the other environment, every request for that token
fails with 401. The client already retries once with a fresh token before
giving up, so a 401 that survives one retry is not a fluke: it means the key
that minted the token and the apiBase you're calling don't agree on
environment.
This is easy to miss because both environments issue keys that look the
same — sk_live_… is not a production-only prefix. If you're getting a
persistent ApiError(401), check that the key your server used to mint the
token and the apiBase you passed to createClient are for the same
environment.
React
Building a React app? See
@slytrunk/sly-tifo-react
for <SlyTifoProvider> and a set of hooks built on this client.
