Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,24 @@ bun run deploy
screenly edge-app instance create
```

Then set the settings needed for the dashboard to show live data (see [Configuration](#configuration) for the full list):

```bash
screenly edge-app setting set customer_key=<your Puzzel tenant ID>
screenly edge-app setting set user_id=<your Puzzel user ID>
screenly edge-app setting set client_id=<your Puzzel OIDC client ID>
screenly edge-app setting set client_secret=<your Puzzel OIDC client secret>
```

## Configuration

Settings are defined in `screenly.yml` and mirrored in `screenly_qc.yml` (used for the QC/staging build).

| Setting | Description | Required | Default |
| ------------------- | ----------------------------------------------------------------------------------------------------------- | -------- | ------- |
| `access_token` | For testing only. Production support for Puzzel ID OAuth is not implemented yet. | No | — |
| `client_id` | Your Puzzel OIDC client ID. For demo use only, until a Screenly OAuth broker for Puzzel exists. | No | — |
| `client_secret` | Your Puzzel OIDC client secret. For demo use only, until a Screenly OAuth broker for Puzzel exists. | No | — |
| `customer_key` | Your Puzzel customer number. | Yes | — |
| `display_errors` | For debugging purposes to display errors on the screen. | No | `false` |
| `override_locale` | Override the default locale with a supported language code (e.g., en_US, fr_FR, de_DE). | No | `en` |
Expand Down
40 changes: 40 additions & 0 deletions bin/initialize-puzzel-app-instance
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail

if [ $# -lt 2 ]; then
echo "Usage: $0 <slug> <display-name>" >&2
echo "Example: $0 customer-a 'Customer A'" >&2
exit 1
fi

missing=()
for var in PUZZEL_CUSTOMER_KEY PUZZEL_USER_ID PUZZEL_CLIENT_KEY PUZZEL_CLIENT_SECRET; do
if [ -z "${!var:-}" ]; then
missing+=("${var}")
fi
done

if [ ${#missing[@]} -gt 0 ]; then
echo "Missing required environment variable(s): ${missing[*]}" >&2
exit 1
fi

slug="$1"
name="$2"
dir="instances/${slug}"

mkdir -p "${dir}"

if [ ! -e "${dir}/screenly.yml" ]; then
ln -s "../../screenly.yml" "${dir}/screenly.yml"
fi

screenly edge-app instance create --name="${name}" -p "${dir}"

screenly edge-app setting set "customer_key=${PUZZEL_CUSTOMER_KEY}" -p "${dir}"
screenly edge-app setting set "user_id=${PUZZEL_USER_ID}" -p "${dir}"
screenly edge-app setting set "client_id=${PUZZEL_CLIENT_KEY}" -p "${dir}"
screenly edge-app setting set "client_secret=${PUZZEL_CLIENT_SECRET}" -p "${dir}"

echo
echo "Instance created and configured in ${dir}/."
11 changes: 11 additions & 0 deletions screenly.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
syntax: manifest_v1
id: 01M0JSRMTH97BP08CQ8Q2ZGNQ3
description: Display real-time Puzzel Contact Centre queue and agent stats on your Screenly digital signage screens
categories:
- Dashboards
Expand All @@ -10,6 +11,16 @@ settings:
title: Access Token
optional: true
help_text: For testing only. Production support for Puzzel ID OAuth is not implemented yet.
client_id:
type: string
title: Client ID
optional: true
help_text: Your Puzzel OIDC client ID. For demo use only, until a Screenly OAuth broker for Puzzel exists.
client_secret:
type: secret
title: Client Secret
optional: true
help_text: Your Puzzel OIDC client secret. For demo use only, until a Screenly OAuth broker for Puzzel exists.
customer_key:
type: string
title: Customer Key
Expand Down
10 changes: 10 additions & 0 deletions screenly_qc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ settings:
title: Access Token
optional: true
help_text: For testing only. Production support for Puzzel ID OAuth is not implemented yet.
client_id:
type: string
title: Client ID
optional: true
help_text: Your Puzzel OIDC client ID. For demo use only, until a Screenly OAuth broker for Puzzel exists.
client_secret:
type: secret
title: Client Secret
optional: true
help_text: Your Puzzel OIDC client secret. For demo use only, until a Screenly OAuth broker for Puzzel exists.
customer_key:
type: string
title: Customer Key
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const API_BASE_URL = 'https://api.puzzel.com/ContactCentre5'
export const PUZZEL_TOKEN_URL = 'https://app.puzzel.com/id/connect/token'
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 15
export const TICKER_PERIOD_WINDOW = 'Today'
export const VISUAL_QUEUE_RESULT = 'All'
Expand Down
71 changes: 70 additions & 1 deletion src/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,25 @@ import {
} from 'bun:test'
import { resetScreenlyMock, setupScreenlyMock } from '@screenly/edge-apps/test'
import * as utils from '@screenly/edge-apps/utils'
import { fetchAccessToken } from './credentials'
import { clientCredentialsCache, fetchAccessToken } from './credentials'

const BASE_SETTINGS = {
access_token: '',
client_id: '',
client_secret: '',
customer_key: '12345',
user_id: '67890',
display_errors: 'false',
screenly_oauth_tokens_url: 'https://api.example.com/oauth/',
screenly_app_auth_token: 'app-auth',
}

const CLIENT_CREDENTIALS_SETTINGS = {
...BASE_SETTINGS,
client_id: 'my-client-id',
client_secret: 'secret',
}

const reportError = spyOn(utils, 'reportError')
const readEdgeAppCache = spyOn(utils, 'readEdgeAppCache')
const writeEdgeAppCache = spyOn(utils, 'writeEdgeAppCache')
Expand Down Expand Up @@ -46,6 +56,8 @@ describe('fetchAccessToken', () => {
reportError.mockReset().mockImplementation(() => {})
readEdgeAppCache.mockReset().mockReturnValue(null)
writeEdgeAppCache.mockReset().mockImplementation(() => {})
clientCredentialsCache.accessToken = ''
clientCredentialsCache.expiresAt = 0
})

afterEach(() => {
Expand All @@ -65,6 +77,63 @@ describe('fetchAccessToken', () => {
expect(fetchMock).not.toHaveBeenCalled()
})

test('exchanges client_id/client_secret for a token, taking priority over access_token', async () => {
setupScreenlyMock(
{},
{
...BASE_SETTINGS,
access_token: 'dev-token',
client_id: 'my-client-id',
client_secret: 'my-client-secret',
},
)
const fetchMock = fakeResponse(200, {
access_token: 'client-creds-token',
expires_in: 900,
})

const token = await fetchAccessToken()

expect(token).toBe('client-creds-token')
const [requestUrl, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(requestUrl).toBe('https://app.puzzel.com/id/connect/token')
expect(init.body?.toString()).toBe(
new URLSearchParams({
client_id: 'my-client-id',
client_secret: 'my-client-secret',
scope: 'contact-centre:12345:67890',
grant_type: 'client_credentials',
}).toString(),
)
})

test('reuses a cached client-credentials token instead of refetching', async () => {
setupScreenlyMock({}, CLIENT_CREDENTIALS_SETTINGS)
const fetchMock = fakeResponse(200, {
access_token: 'client-creds-token',
expires_in: 900,
})

await fetchAccessToken()
const token = await fetchAccessToken()

expect(token).toBe('client-creds-token')
expect(fetchMock).toHaveBeenCalledTimes(1)
})

test('falls back to the cache when the client-credentials exchange fails', async () => {
setupScreenlyMock({}, CLIENT_CREDENTIALS_SETTINGS)
readEdgeAppCache.mockReturnValue({ accessToken: 'cached-token' })
stubFetch(async () => {
throw new Error('network down')
})

const token = await fetchAccessToken()

expect(token).toBe('cached-token')
expect(reportError).toHaveBeenCalledTimes(1)
})

test('returns the fetched token and writes it to cache on success', async () => {
fakeResponse(200, { token: 'live-token' })

Expand Down
116 changes: 89 additions & 27 deletions src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,104 @@ import {
reportError,
writeEdgeAppCache,
} from '@screenly/edge-apps/utils'
import { CACHE_NAMESPACE } from './constants'
import { CACHE_NAMESPACE, PUZZEL_TOKEN_URL } from './constants'

type CachedCredentials = { accessToken: string }

interface PuzzelTokenResponse {
access_token?: string
expires_in?: number
error?: string
error_description?: string
}

export const clientCredentialsCache: {
accessToken: string
expiresAt: number
} = {
accessToken: '',
expiresAt: 0,
}

async function fetchClientCredentialsToken(
clientId: string,
clientSecret: string,
): Promise<string> {
if (clientCredentialsCache.expiresAt > Date.now()) {
return clientCredentialsCache.accessToken
}

const customerKey = getSettingWithDefault<string>('customer_key', '')
const userId = getSettingWithDefault<string>('user_id', '')

const response = await fetch(PUZZEL_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
scope: `contact-centre:${customerKey}:${userId}`,
grant_type: 'client_credentials',
}),
})

const body = (await response.json()) as PuzzelTokenResponse
if (!response.ok || !body.access_token) {
throw new Error(
body.error_description ??
body.error ??
`Puzzel token request failed (${response.status}).`,
)
}

clientCredentialsCache.accessToken = body.access_token
clientCredentialsCache.expiresAt =
Date.now() + (body.expires_in ?? 900) * 1000 - 30_000
writeEdgeAppCache(CACHE_NAMESPACE, 'credentials', {
accessToken: body.access_token,
})
return body.access_token
}

async function fetchBrokerToken(): Promise<string> {
const oauthTokensUrl = String(screenly.settings.screenly_oauth_tokens_url)
const url = new URL(
'access_token/',
oauthTokensUrl.endsWith('/') ? oauthTokensUrl : `${oauthTokensUrl}/`,
)
const response = await fetch(url, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${screenly.settings.screenly_app_auth_token}`,
},
})
if (!response.ok) {
throw new Error(
`Screenly returned an unexpected error (${response.status}).`,
)
}

const body = (await response.json()) as { token?: string }
if (!body.token) throw new Error('No access token available.')

writeEdgeAppCache(CACHE_NAMESPACE, 'credentials', { accessToken: body.token })
return body.token
}

export async function fetchAccessToken(): Promise<string> {
const clientId = getSettingWithDefault<string>('client_id', '')
const clientSecret = getSettingWithDefault<string>('client_secret', '')
const devAccessToken = getSettingWithDefault<string>('access_token', '')
if (devAccessToken) return devAccessToken

const displayErrors =
getSettingWithDefault<string>('display_errors', 'false') === 'true'

if ((!clientId || !clientSecret) && devAccessToken) return devAccessToken

try {
const oauthTokensUrl = String(screenly.settings.screenly_oauth_tokens_url)
const url = new URL(
'access_token/',
oauthTokensUrl.endsWith('/') ? oauthTokensUrl : `${oauthTokensUrl}/`,
)
const response = await fetch(url, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${screenly.settings.screenly_app_auth_token}`,
},
})
if (!response.ok) {
throw new Error(
`Screenly returned an unexpected error (${response.status}).`,
)
if (clientId && clientSecret) {
return await fetchClientCredentialsToken(clientId, clientSecret)
}

const body = (await response.json()) as { token?: string }
if (!body.token) throw new Error('No access token available.')

writeEdgeAppCache(CACHE_NAMESPACE, 'credentials', {
accessToken: body.token,
})
return body.token
return await fetchBrokerToken()
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
reportError(error, { source: 'puzzel-credentials' })
Expand All @@ -49,6 +111,6 @@ export async function fetchAccessToken(): Promise<string> {
CACHE_NAMESPACE,
'credentials',
)
return cached?.accessToken ?? ''
return cached?.accessToken ?? devAccessToken
}
}