@conapps/feedback-sdk
v0.1.2
Published
TypeScript SDK for the platform Feedback service — sign identity tokens for the embedded widget and read/write boards from your own UI.
Maintainers
Readme
@conapps/feedback-sdk
Everything a product needs around the platform's Feedback service: sign identity tokens for the embedded widget, and — if you are building your own UI — a typed client for the public board API.
npm install @conapps/feedback-sdkThree entry points, split by where they run:
| Import | Runs | For |
|---|---|---|
| @conapps/feedback-sdk/identity | your backend | signing identity tokens (needs node:crypto) |
| @conapps/feedback-sdk/widget | the browser | typed wiring for the embedded widget |
| @conapps/feedback-sdk | either | the public board API, for your own UI |
Which of these do you need?
A board on a page → none of them. Paste the embed snippet from the board's
admin screen. One <script> tag, and it keeps working when the API changes.
A board that knows who is logged in → /identity. That is the common case,
and the one this package mostly exists for. Read on — starting at
Getting the two values a board has if you
do not have the board's keys yet.
Your own feedback UI → the client. Read Own UI below.
Getting the two values a board has
Everything below assumes you already hold the board's public key and its signing key. Both are on the board's screens in the backoffice — but if you only have API credentials, you can do the whole setup over the API. The one thing you must not do is skip past this: the signing key is shown once.
BASE="https://api.conapps.net/api/v1/workspaces/$WORKSPACE_ID/services/feedback"
# 1. Create the board. `name` and `slug` are both required; slug is lowercase
# a-z, 0-9 and single hyphens. The response carries the signing key IN CLEAR,
# this one time only.
curl -X POST "$BASE/boards" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"Product feedback","slug":"product-feedback"}'
# → { "board": { "id": "...", "publicKey": "...", ... }, "signingKey": "a1b2…" }
# 2. Named users instead of anonymous ones.
curl -X PATCH "$BASE/boards/$BOARD_ID" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"identityMode":"authenticated"}'Store
signingKeybefore you close the terminal. It is encrypted at rest;GET /boards/{id}only ever answershasSigningKey: true. The one way to get a key back is to replace it:curl -X POST "$BASE/boards/$BOARD_ID/rotate-key" -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' -d '{"confirm":true}'
confirm: trueis required and is not a formality — rotating invalidates every identity token already issued, so on a live board everyone is signed out until your backend has the new key. The response carries the new key once, exactly like creation. Capture it in the same command; an integrator testing this lost a freshly rotated key to a shell loop and had to rotate twice.
Reading a board back
curl -s "$BASE/boards" -H "Authorization: Bearer $TOKEN" # list, with publicKey
curl -s "$BASE/boards/$BOARD_ID" -H "Authorization: Bearer $TOKEN"A board carries four identifiers and it is worth keeping them apart:
| | Where it goes | Secret? |
|---|---|---|
| id | the management API paths above | no |
| publicKey | data-feedback-board in your page, and boardKey in this SDK | no |
| slug | the hosted stand-alone page | no |
| signingKey | your backend only, to sign identity tokens | yes |
The full board API — every field, every default — is the service's OpenAPI document, served through the gateway:
curl -s "$BASE/docs-json" | jq '.paths | keys'Note the host: docs-json is behind the gateway, like the rest of the
management API. The feedback. host serves only the public board surface and
answers 404 for it.
Identity: why your backend signs
A board in authenticated mode shows a person's name on their post and lets them
vote once. The feedback service cannot check a login in your product — so it
does not try. You are the identity authority for your own users: your backend
signs a short-lived token with the board's signing key, the widget carries it, and
the service verifies it.
If the service minted these tokens, anyone who could reach the mint could assert any identity. This way the signing key never leaves your backend.
The token is deliberately not a JWT — no algorithm field to confuse, no
alg: none to fall into:
base64url(JSON payload) + "." + base64url(HMAC-SHA256(key, payloadPart))That is four lines in any language with an HMAC. It was also four lines every
integrator wrote from a formula in a docs page — and base64url padding or an MAC
taken over the decoded object instead of the encoded string both fail as an
indistinguishable bad_signature. Hence:
// server — Express, Nuxt, Next, Fastify, whatever you have
import { signIdentityToken } from '@conapps/feedback-sdk/identity';
app.get('/api/feedback-token', requireLogin, (req, res) => {
res.type('text/plain').send(
signIdentityToken(
{ sub: req.user.id, name: req.user.displayName, email: req.user.email },
process.env.FEEDBACK_SIGNING_KEY!,
),
);
});<!-- page — before the loader script; it merges, so either order is safe -->
<script>
window.FeedbackWidget = {
getToken: () => fetch('/api/feedback-token').then((r) => r.text()),
};
</script>
<div data-feedback-widget></div>
<script async src="https://feedback.conapps.net/public/widget/loader.js"
data-feedback-board="YOUR_BOARD_PUBLIC_KEY"></script>The signing key is on the board's Identity screen in the backoffice. Server only: in a browser bundle every visitor can read it and post as anyone.
The payload
| Field | | |
|---|---|---|
| sub | required | Your stable user id. Posts and votes hang off it — an email the user can change, or a session id, silently splits one person into two. |
| exp | required | Unix seconds, at most 24 h ahead. Omit it and pass lifetimeSeconds instead (default 1 h). |
| name | optional | Shown next to posts and comments. |
| email | optional | Lets the board offer "notify me" without retyping. Its presence is not consent — the checkbox is. |
exp is seconds, and the JavaScript reflex is Date.now(), which is
milliseconds and lands the expiry in the year 56000. That is refused with a
message saying so, rather than honoured — but the easiest way to avoid it is to
leave exp out:
signIdentityToken({ sub: 'user-42' }, key, { lifetimeSeconds: 900 });Verifying in your own tests
import { verifyIdentityToken, IdentityTokenError } from '@conapps/feedback-sdk/identity';You never need this in production — the service verifies. It is here so you can
assert that what you sign is what the board will accept without standing a
service up. IdentityTokenError.code is one of malformed, bad_signature,
expired, lifetime_too_long, missing_subject.
The wire format is pinned to a fixed test vector on both sides — here and in the service's own suite. Two implementations of a MAC drift in ways a round-trip test never sees, because each round-trips happily against itself.
Widget wiring (TypeScript)
import { configureFeedbackWidget } from '@conapps/feedback-sdk/widget';
configureFeedbackWidget({
getToken: () => fetch('/api/feedback-token').then((r) => r.text()),
});Merges into window.FeedbackWidget instead of replacing it, and types
getToken/setTheme. Two things it protects you from:
- Ordering. The loader tag is
asyncand may run before or after your code. It merges too — so assigning before it is safe. Assigning after it, in a framework'sonMounted, means the first identity request was already answered withnulland the visitor is anonymous for the rest of the page. Call this at module top level, or from a plain inline<script>. - Staleness.
getTokenis called each time the frame needs an identity. Return a current token; do not close over one fetched at page load.
Also here:
import { feedbackEmbedSnippet, feedbackCspDirectives } from '@conapps/feedback-sdk/widget';
feedbackEmbedSnippet({ endpoint, boardKey, launcher: 'right', label: 'Feedback' });
const nonce = crypto.randomUUID(); // per response, never reused
feedbackCspDirectives(endpoint, nonce);
// { 'script-src': ['https://feedback.conapps.net', "'nonce-…'"], 'frame-src': [...], … }Pass the nonce whenever you set getToken from an inline <script> — and
render that tag with the same nonce. Without it, the two pieces of advice on
this page work against each other: the ordering advice says to use a plain
inline script, and merging these directives into a strict policy blocks exactly
that script. The widget still loads and the frame still renders; only getToken
is never defined, so every visitor stays anonymous. You get one console line and
nothing else — and the symptom is identical to assigning too late.
A missing frame-src is the usual cause of "the snippet is on the page and
nothing appears" — the browser blocks the iframe with a console entry and no
visible error.
Own UI
import { createFeedbackClient } from '@conapps/feedback-sdk';
const board = createFeedbackClient({
endpoint: 'https://feedback.conapps.net', // or the board's custom domain
boardKey: 'fb_pub_1a2b', // = data-feedback-board
identity: () => fetch('/api/feedback-token').then((r) => r.text()),
locale: 'de-DE',
});
const config = await board.getBoard(); // read this BEFORE rendering
const { items, nextCursor } = await board.listPosts({ sort: 'top' });
await board.vote(items[0].id, 1);
await board.comment(items[0].id, 'Yes please');No platform credentials here. The public board API takes no API-integration token — it is called from a visitor's browser. The only secret in this package is the board signing key, and that one stays on your server.
endpointis the feedback service's own public host, not the API gateway.
Read the board first
getBoard() is not optional ceremony. identityMode, downvotesEnabled,
allowComments, allowAttachments and allowReactions each change what your UI
may offer, and guessing produces a button that always fails. It also returns the
brand colours, the sanitised customCss, and the submission form already
flattened into one language — so you never carry translation logic.
Identity modes
| Mode | Who may write | What you pass |
|---|---|---|
| anonymous | anyone | deviceToken — a per-device string you generate once and keep |
| authenticated | signed identities only | identity |
| optional | both | either |
deviceToken is a self-chosen string with no signature: it identifies a device
well enough to stop a double-click, and not well enough to be a security
boundary. The service stores only a peppered hash of it.
Methods
board.getBoard() / getViewer()
board.listPosts({ sort, cursor, search }) / getPost(id)
board.getRoadmap() / getChangelog()
board.createPost(input) / vote(id, 1 | -1) / unvote(id)
board.react(id, emoji) / comment(id, body, parentId?)
board.attach(id, file, { kind, fileName }) / attachmentUrl(path)Two shapes that surprise people:
- A merged post keeps answering its own URL — that is what the tombstone is for — but answers with the canonical post. The id you get back may differ from the one you asked for; use it when you fix up the address bar.
- Attachments return a
path, not a URL. The same board is served from the widget host, a hosted page and possibly a custom domain, and only you know which one you are rendering on.attachmentUrl(path)joins them.
Errors
import {
FeedbackError, FeedbackIdentityError,
FeedbackNotFoundError, FeedbackRateLimitError, FeedbackValidationError,
} from '@conapps/feedback-sdk';Everything this SDK throws extends FeedbackError. One catch on the base
class is complete.
| Class | When |
|---|---|
| FeedbackError | base class, and anything not listed below — .status, .url, .body |
| FeedbackValidationError | the SDK refused before sending |
| FeedbackConnectionError | the request never arrived (DNS, refused, timeout, TLS) — .code, .endpoint |
| FeedbackIdentityError | 401/403 — missing, expired, or wrongly signed identity |
| FeedbackNotFoundError | 404 — wrong board key, a hidden post, or the wrong host (the message says which) |
| FeedbackRateLimitError | 429 — .retryAfterSeconds |
status is the HTTP status — 0 when there was no response.
Every method is async, including the ones whose local checks fail before any
request goes out.
Troubleshooting
| Symptom | Cause |
|---|---|
| Snippet on the page, nothing renders | CSP: frame-src for the feedback origin is missing. |
| Bad Request Exception with no field named | The service does not serialise its field list. Creating a board needs name + slug; rotate-key needs {"confirm":true}. |
| Everyone is anonymous although they are logged in | Two different causes, same symptom. (1) Your CSP blocks the inline script that sets getToken — check the console for a script-src violation and pass a nonce to feedbackCspDirectives(endpoint, nonce). (2) window.FeedbackWidget was assigned after the loader ran (onMounted); move it to module top level. Rule out the CSP first — it leaves a console line, the ordering mistake leaves nothing. |
| bad_signature on every token | The key is not this board's, or the MAC was taken over the decoded payload. Use /identity. |
| Works for an hour, then everyone is anonymous | getToken closes over a token fetched at page load. It must fetch. |
| lifetime_too_long | exp in milliseconds. Use seconds, or lifetimeSeconds. |
| 403 on a board that worked yesterday | The board's signing key was rotated — every token issued before that is now invalid. |
| Signing key lost, no endpoint reads it back | By design. rotate-key is the only way, and it signs everyone out. |
| validation_failed / Bad Request with no field named | Creating a board needs name and slug; the slug must match ^[a-z0-9]+(?:-[a-z0-9]+)*$. |
| 404 on /docs-json | It lives behind the gateway, not on the feedback. host. |
| 404 on a post that exists | The team hid it — deliberately indistinguishable from never having existed. |
| Attachment <img> 404s | path used as a URL. Prefix it with attachmentUrl(). |
| 422 downvotes_not_enabled | vote(id, -1) on a board without downvotesEnabled. Check getBoard(). |
Flutter
For a Flutter app there is a ready-made package instead of any of this:
feedback_kit on pub.dev. It needs the
same three values — endpoint, board public key, and a token from your backend.
Build & test
npm install
npm run build # tsup → dist/{index,identity,widget}.{js,cjs,d.ts}
npm test # vitest — 69 unit tests
npx vitest run --coverage # ≥ 99% line coverage on src/