@chaimp-org/feedback-ado
The ChAImp SaaS Universal Feedback Engine — one implementation every product installs to turn a user's beta feedback into an Azure DevOps work item, authenticated by the product's Entra app registration over Vercel OIDC federation (no secret stored anywhere).
Why
Every product had (or needed) its own copy of "feedback → tracker". This is the single, shared, verified implementation: Bug/User-Story split, Markdown bodies, tags, priority-support routing, and a resilient best-effort call that never throws (the product DB row stays the source of truth).
Install
pnpm add @chaimp-org/feedback-ado
Usage
In a server route, build the work item from normalised feedback and file it
under waitUntil (a bare fire-and-forget promise is frozen when the serverless
response returns, before the ADO create can finish):
import { waitUntil } from "@vercel/functions";
import { adoApiEnabled, buildFeedbackWorkItem, createAdoWorkItem } from "@chaimp-org/feedback-ado";
// ... after persisting the feedback row (source of truth) ...
if (adoApiEnabled()) {
waitUntil(
createAdoWorkItem(
buildFeedbackWorkItem({
category: parsed.category, // "bug" → Bug, else User Story
message: parsed.message,
orgName: ctx.org.name,
orgSlug: ctx.org.slug,
userEmail: ctx.userEmail,
plan: ctx.org.plan,
page: parsed.page,
priority: isPro, // ⚡ + Priority 1 + priority-support tag
envLabel: process.env.NEXT_PUBLIC_ENV_LABEL,
}),
).catch(() => {}),
);
}
buildFeedbackWorkItem is optional — call createAdoWorkItem(item) directly if
you want to shape the work item yourself.
Through ATFS first (0.5.0)
submitFeedback sends the feedback to the portal's ATFS intake
(POST /api/atfs/intake). The portal triages it and files it. When the portal
definitely did not take the feedback, submitFeedback files it directly in ADO, as
in 0.4. It never throws.
import { waitUntil } from "@vercel/functions";
import { submitFeedback } from "@chaimp-org/feedback-ado";
waitUntil(
submitFeedback(
{
category: parsed.category,
message: parsed.message,
product: "cartoncrew",
orgId: ctx.org.id, // the product's org id (the portal's tenant external_ref)
orgName: ctx.org.name,
orgSlug: ctx.org.slug,
userEmail: ctx.userEmail,
page: parsed.page,
},
{ idempotencyKey: feedbackRow.id }, // your stored row id: a re-submit is then safe too
).then((r) => {
if (!r.ok) console.warn("[feedback]", r.via, r.reason); // r.reason never holds a token
}),
);
Results:
via | ok | Meaning |
|---|---|---|
atfs | true | The portal stored it. Nothing was filed directly |
ado | true | Filed directly. reason says why (portal not configured, ATFS off, unreachable, refused) |
ado | false | The portal did not take it and the direct filing failed too |
uncertain | false | The portal may have stored it (a lost answer) and no retry settled it. Nothing was filed directly, so it cannot be filed twice. Keep your row and submit again later with the same idempotencyKey |
The no-double-filing rule. Direct filing happens only when the portal definitely did not store the submission. That means one of these happened:
- the portal is not configured;
- the portal answered
accepted: false; - before any unknown outcome, the connection never opened or the portal returned a 4xx.
A 503 is a refusal only when its body is the intake's own JSON refusal
(accepted: false); a bare 503 from the platform or a proxy is unknown (0.7.0).
A timeout after the request went out is unknown, and so are a reset and a 500, 502 or
504. An unknown outcome is retried with the same idempotency key. The portal keeps
one item per key per product, so the retry returns the stored item. Once any attempt
was unknown, only an explicit answer from the portal settles the outcome. Direct
fallback filings are tagged atfs-fallback.
Direct filing is idempotent (0.7.0). A direct filing carries the tag
fbk-<hash> (idemKeyTag(key): the first 32 hex characters of the SHA-256 of the
idempotency key). Before it creates, it looks for an item with that tag by WIQL. If one
exists, that id is returned with duplicate: true and nothing is filed. If the lookup
fails, nothing is filed and the result is ok: false; submit again later with the same
key.
Options: idempotencyKey (default: a random UUID), portal (overrides the env; pass
null to skip the portal), retries (default 2), timeoutMs (default 15000, above
undici's 10 s connect timeout), retryDelayMs (default 500), surface, plus every
createAdoWorkItemId option for the fallback.
Configuration (environment)
Same variable names across every product; set them in the product's Vercel
project (and .env.secrets.local for the local PAT fallback):
| Var | Purpose |
|---|---|
ADO_ORG | organisation, e.g. chaimporg |
ADO_PROJECT | project, e.g. One |
ADO_AREA_PATH | required per product, e.g. One\SaaS\<slug> (omitted → project default) |
ADO_TENANT_ID | Entra directory (tenant) id — preferred auth |
ADO_CLIENT_ID | Entra application (client) id — preferred auth |
ADO_PAT | local-dev fallback only; used when ADO_TENANT_ID/ADO_CLIENT_ID are unset |
ATFS_INTAKE_URL | 0.5.0: the portal base URL, https (for example https://portal.chaimp.org). Unset means direct filing only |
ATFS_INTAKE_TOKEN | 0.5.0: this product's intake token. The portal holds the same value as ATFS_INTAKE_TOKEN_<SLUG>. It is a secret, so keep it in Vercel env only |
Preferred auth — Vercel OIDC federation (secretless)
- Enable OIDC Federation on the Vercel project.
- On the app registration, add a federated credential: issuer
https://oidc.vercel.com/<team>, subjectowner:<team>:project:<vercel-project>:environment:<env>, audienceapi://AzureADTokenExchange. - Add the service principal to the ADO org with Contribute on the area path.
The package exchanges the injected VERCEL_OIDC_TOKEN for an Azure DevOps access
token (resource 499b84ac-…/.default) via @azure/identity's
ClientAssertionCredential. No secret is stored.
Surfacing (product vs stage)
The engine and payload are shared; how feedback is surfaced in the UI is the only
thing that varies between products (copy/framing, categories, placement), so it is
configuration, not code. Declare a FeedbackSurface per product:
const surface: FeedbackSurface = {
product: "knowledgeretention",
stage: "beta", // "ga" once it graduates — no code change
title: "Beta feedback", // GA products: "Send feedback" / "Help & feedback"
categories: [
{ value: "bug", label: "Report a bug", isBug: true },
{ value: "idea", label: "Suggest something" },
{ value: "general", label: "General" },
],
placement: "beta-badge", // GA: "help-menu" | "footer" | "nav"
};
createAdoWorkItem(buildFeedbackWorkItem({ category, message, /* … */ }, surface));
product + stage flow into the work item's tags (beta/ga, product slug) and
body, so triage distinguishes a KR beta bug from a CartonCrew GA bug (on top of the
area path). "Beta" is not special-cased — it is stage.
API
submitFeedback(input, opts?): Promise<SubmitFeedbackResult>(0.5.0) sends the feedback to the portal's ATFS intake first. It falls back to direct filing only when the portal definitely did not store it (see above).idemKeyTag(key): string(0.7.0) — the ADO tag that carries an idempotency key.atfsIntakeConfig(): AtfsPortalConfig | null(0.5.0) readsATFS_INTAKE_URL+ATFS_INTAKE_TOKEN.adoApiEnabled(): boolean— is enough config present to file (else skip; DB is source of truth).createAdoWorkItem(item): Promise<boolean>— file one work item; never throws.createAdoWorkItemId(item, opts?): Promise<number | null>— same, but returns the new work-item id (null on any failure).opts.idempotencyKey(0.7.0) makes it idempotent: an item already taggedidemKeyTag(key)is returned instead of filing a second one.opts.identity({ tenantId, clientId }) authenticates as a different app registration (e.g. a least-privilege filer),opts.parentIdadds the parent as aHierarchy-Reverserelation (never as a field),opts.areaPathoverridesADO_AREA_PATH,opts.iterationPathsetsSystem.IterationPath,opts.assignedTosetsSystem.AssignedTo(a display name),opts.onFailure(reason)is told a short secret-free reason when nothing was filed,opts.fetchImplinjects the transport for tests. All options are optional; omitting them keeps the earlier behaviour. Since 0.6.0 a Bug gets the Markdown body in both Repro Steps andSystem.Description(the board requires a Description on every item but a Test Case); a User Story is unchanged.buildFeedbackWorkItem(input, surface?): WorkItemInput— the shared house style (title, Markdown body, tags, priority); the optionalsurfacedrives Bug/Story mapping and carries product + stage. Since 0.8.0 the body opens with the heading**Feedback** (the sender's own words, as typed; not verified):and the message as a blockquote (every line prefixed>, every line-break form made a newline,<escaped), then---and the metadata list. A typed- **Env:** productionline stays inside the quote, so the only unquoted metadata lines are the package's own. Each metadata value is kept on one line with its Markdown characters escaped. The title is the plain first non-blank line of the message, never the quoted body. The optionaltitleinput (plain text, scrubbed by the product, never escaped; control characters and line breaks become spaces; at most 120 characters) gives[category] slug: <title>instead, and is sent to the ATFS intake assubject, the Triage subject. Deploy order: the portal must run 0.166.0 or later before a product sendstitle; an older portal refuses the unknownsubject(400), andsubmitFeedbackthen files the item directly in ADO (atfs-fallback), so it is never lost or filed twice, but it skips ATFS triage. Since 0.9.0 (AB#16958) every quoted line is also fully escaped, so the message renders literally:&and<become entities, the Markdown characters (\ ` * _ [ ] ( ) ! > ~ | # $ @) are backslash-escaped, a.or:inside a URL, domain or email address is escaped so nothing auto-links, and a list, heading or setext marker at the line start is escaped. No typed link, image, emphasis, code, heading, list, strikethrough, entity or HTML renders, and nothing loads from a URL the sender chose. Metadata values also escape&and~. Send plain text: text a product already escaped (\-,<) now shows its backslashes and entities. The message-derived title keeps at most 70 code points, so an emoji at the cut is never split. The ATFS intake path is unchanged: it receives the message as plain text.quoteUntrusted(text): string— the escaped blockquote used above, for products that build their own body (0.8.0; fully escaped since 0.9.0).resolveType(category, surface?): WorkItemType— Bug vs User Story for a category (uses the surface'scategories/isBug; defaults tocategory === "bug").- Types:
FeedbackSurface,FeedbackCategory,FeedbackStage,FeedbackInput,WorkItemInput,WorkItemType.