From ee136027e20bd543a59914ad2e1e54decdad9ec2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 14:22:29 +0000 Subject: [PATCH 1/3] Add Accredible as a supported badge source Adds an Accredible connector alongside the existing Canvas/Credly/Parchment sources, so Accredible badges can be pulled directly into Badge Publisher instead of going through a manual JSON paste step. - src/lib/utils/accredible.ts: region config (US/EU/Sandbox), types for the /v1/issuer/all_groups response, and badgeclassFromAccredibleGroup(), which maps a group into Badge Publisher's BadgeClassCTDLExtended shape. - src/lib/stores/badgeSourceStore.ts: 'accredible' BadgeSourceTypeOptions entry, accredible* stores, fetchAccredibleGroups() (paginated fetch via the existing StagingApi/Proxy route, Token-header auth matching Accredible's own export script), and wiring into badgeSetupComplete / normalizedBadges / resetBadgeData. - src/lib/partials/AccredibleConfig.svelte: config panel (environment, API key, terms, load-badges) modeled on ParchmentConfig/CredlyConfig. - src/lib/partials/BadgeSourceConfig.svelte: Accredible radio option and panel wiring. Key behavior: alignment/skill entries without a resolvable targetUrl are dropped rather than passed through with only targetName, because badgeClassBasicSchema (src/lib/utils/badges.ts) requires targetUrl on every alignment entry and rejects the whole achievement otherwise. Verified against a 316-achievement sample from Accredible's own export script: 255/316 (81%) failed import for exactly this reason before this fix. Known open item: badge image retrieval. Accredible's Groups endpoint (per their own export script) does not return a design/image URL, and Accredible's API reference (docs.accredible.com) could not be reached while building this to confirm the right endpoint. extractImageFromGroup() checks a few plausible field names defensively, and ACCREDIBLE_GROUP_IMAGE_ENDPOINT_UNCONFIRMED in accredible.ts is a placeholder for a second per-group lookup call once Accredible confirms the actual endpoint. Until then, image will typically be blank, same as it is today via the manual JSON path. Verified with 'npm run check' (svelte-check): 42 errors on this branch vs. 44 on main, none attributable to the new/changed files. 'npm run build' fails identically on main due to a pre-existing missing env var (PUBLIC_PARCHMENT_SG_ENABLED) unrelated to this change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01F4KiQrHVeCQqSbFHNDUkKV --- src/lib/partials/AccredibleConfig.svelte | 174 ++++++++++++++++++++++ src/lib/partials/BadgeSourceConfig.svelte | 11 ++ src/lib/stores/badgeSourceStore.ts | 100 ++++++++++++- src/lib/utils/accredible.ts | 164 ++++++++++++++++++++ 4 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 src/lib/partials/AccredibleConfig.svelte create mode 100644 src/lib/utils/accredible.ts diff --git a/src/lib/partials/AccredibleConfig.svelte b/src/lib/partials/AccredibleConfig.svelte new file mode 100644 index 0000000..785c282 --- /dev/null +++ b/src/lib/partials/AccredibleConfig.svelte @@ -0,0 +1,174 @@ + + +

Configure Accredible connection

+ + + You'll need an Accredible API key with issuer access, found in Accredible under Settings > + API & Integrations. Use a Sandbox key when testing against a Sandbox account. + + +
+ +
+ + +{#if $accredibleSelectedRegion} +
+ +
+
+
+ + {#if accredibleApiKeyHidden} + + {:else} + + {/if} +
+
+{/if} + +{#if $accredibleSelectedRegion && $accredibleApiKey} +
+ +
+
+
+ + +
+
+{/if} + +{#if $accredibleSelectedRegion && $accredibleApiKey && $accredibleAgreeTerms} +
+ +
+ {#await loadGroupsPromise} +
+
+
+ Loading... +
+ {:then} + {#if $accredibleGroups.length} +
+ + Found {$accredibleGroups.length} + {$accredibleGroups.length == 1 ? 'badge' : 'badges'} on Accredible. + + + Skill/outcome alignments that don't resolve to a URL (framework code) will be omitted + from the imported data, since Badge Publisher requires a URL on every alignment entry. + +
+ {:else} +
+ +
+ {/if} + {:catch} + + {/await} +{/if} diff --git a/src/lib/partials/BadgeSourceConfig.svelte b/src/lib/partials/BadgeSourceConfig.svelte index fdda1dd..d153eed 100644 --- a/src/lib/partials/BadgeSourceConfig.svelte +++ b/src/lib/partials/BadgeSourceConfig.svelte @@ -9,6 +9,7 @@ import NextPrevButton from '$lib/components/NextPrevButton.svelte'; import CanvasConfig from '$lib/partials/CanvasConfig.svelte'; import CredlyConfig from '$lib/partials/CredlyConfig.svelte'; + import AccredibleConfig from '$lib/partials/AccredibleConfig.svelte'; import AdvancedBadgeInput from './AdvancedBadgeInput.svelte'; import BadgeSelection from '$lib/partials/BadgeSelection.svelte'; import { @@ -110,6 +111,14 @@ on:select={(e) => ($badgeSourceType = e.detail.value)} description="A leading badge platform focused on resume-ready achievements in education, workforce, and professional development." /> + ($badgeSourceType = e.detail.value)} + description="A digital credentialing platform for badges, certificates, and diplomas." + /> {:else if $badgeSourceType == 'credly'} + {:else if $badgeSourceType == 'accredible'} + {:else if $badgeSourceType == 'json'} {/if} diff --git a/src/lib/stores/badgeSourceStore.ts b/src/lib/stores/badgeSourceStore.ts index 1fdd16a..11817d6 100644 --- a/src/lib/stores/badgeSourceStore.ts +++ b/src/lib/stores/badgeSourceStore.ts @@ -11,9 +11,17 @@ import { writable, derived, get, type Readable } from 'svelte/store'; import { PUBLIC_UI_API_BASEURL } from '$env/static/public'; import { publisherUser } from '$lib/stores/publisherStore.js'; import { badgeclassFromParchmentApiBadge, type ParchmentBadge, type ParchmentEnvKey, type ParchmentIssuer, parchmentRegions } from '$lib/utils/parchment.js'; +import { + accredibleAuthHeader, + accredibleRegions, + badgeclassFromAccredibleGroup, + type AccredibleEnvKey, + type AccredibleGroup +} from '$lib/utils/accredible.js'; export enum BadgeSourceTypeOptions { None = '', + Accredible = 'accredible', Canvas = 'canvas', Credly = 'credly', JSON = 'json', @@ -175,6 +183,68 @@ export const fetchParchmentIssuerBadges = async (): Promise => { return true; }; +// Accredible configuration +export const accredibleApiKey = writable(''); +export const accredibleAgreeTerms = writable(false); +export const accredibleSelectedRegion = writable(''); +export const accredibleGroups = writable([]); + +export const fetchAccredibleGroups = async (): Promise => { + const region = get(accredibleSelectedRegion); + const apiKey = get(accredibleApiKey); + if (!region || !get(accredibleAgreeTerms) || !apiKey) return false; + + const env = accredibleRegions.get(region); + if (!env) return false; + + const proxyRequestHeaders = new Headers(); + proxyRequestHeaders.append('Content-Type', 'application/json'); + if (get(publisherUser).user?.Token) + proxyRequestHeaders.append('Authorization', `Bearer ${get(publisherUser).user?.Token}`); + + // Accredible's `/v1/issuer/all_groups` endpoint is paginated (page/page_size, + // matching Accredible's own export script). We page through until a page + // comes back with fewer than page_size results. + const pageSize = 50; + let page = 1; + let allGroups: AccredibleGroup[] = []; + + // eslint-disable-next-line no-constant-condition + while (true) { + const requestData = { + URL: `${env.apiDomain}/v1/issuer/all_groups?page=${page}&page_size=${pageSize}`, + Method: 'GET', + Body: null, + Headers: [accredibleAuthHeader(apiKey), { Name: 'Accept', Value: 'application/json' }] + }; + + const proxyResponse = await fetch(`${PUBLIC_UI_API_BASEURL}/StagingApi/Proxy`, { + method: 'POST', + body: JSON.stringify(requestData), + headers: proxyRequestHeaders + }); + const proxyResponseData = await proxyResponse.json(); + + if (!proxyResponseData.Valid || proxyResponseData.Data?.StatusCode != '200') + throw new Error('Error fetching group data from Accredible.'); + + const body = JSON.parse(proxyResponseData.Data?.Body); + // NOTE: the exact envelope key returned by this endpoint was not confirmed + // against Accredible's API reference (unreachable while building this). + // Handle a few plausible shapes defensively; adjust once confirmed. + const pageGroups: AccredibleGroup[] = body.groups || body.all_groups || (Array.isArray(body) ? body : []); + + allGroups = [...allGroups, ...pageGroups]; + + if (pageGroups.length < pageSize) break; + page += 1; + if (page > 200) break; // safety valve against an unexpected infinite loop + } + + accredibleGroups.set(allGroups); + return true; +}; + // Advanced JSON setup export const advancedBadges = writable>([]); export const advancedBadgesFound = derived( @@ -196,7 +266,11 @@ export const badgeSetupComplete = derived( parchmentAgreeTerms, parchmentSelectedRegion, parchmentSelectedIssuer, - parchmentOrganization + parchmentOrganization, + accredibleApiKey, + accredibleAgreeTerms, + accredibleSelectedRegion, + accredibleGroups ], ([ $advancedBadgesFound, @@ -209,7 +283,11 @@ export const badgeSetupComplete = derived( $parchmentAgreeTerms, $parchmentSelectedRegion, $parchmentSelectedIssuer, - $parchmentOrganization + $parchmentOrganization, + $accredibleApiKey, + $accredibleAgreeTerms, + $accredibleSelectedRegion, + $accredibleGroups ]) => { if ($badgeSourceType == BadgeSourceTypeOptions['Credly']) { return ( @@ -226,6 +304,13 @@ export const badgeSetupComplete = derived( !!$parchmentSelectedIssuer && !!$parchmentOrganization ); + } else if ($badgeSourceType == BadgeSourceTypeOptions['Accredible']) { + return ( + !!$accredibleApiKey && + !!$accredibleAgreeTerms && + !!$accredibleSelectedRegion && + !!$accredibleGroups.length + ); } else { return !!$advancedBadgesFound.length; } @@ -242,6 +327,8 @@ export const normalizedBadges: Readable = derived( canvasSelectedIssuerBadges, credlyIssuerBadges, parchmentSelectedIssuerBadges, + accredibleGroups, + accredibleSelectedRegion, advancedBadgesFound ], ([ @@ -250,6 +337,8 @@ export const normalizedBadges: Readable = derived( $canvasSelectedIssuerBadges, $credlyIssuerBadges, $parchmentSelectedIssuerBadges, + $accredibleGroups, + $accredibleSelectedRegion, $advancedBadgesFound ]) => { if (!$badgeSetupComplete) { @@ -263,6 +352,10 @@ export const normalizedBadges: Readable = derived( return $credlyIssuerBadges.map(badgeclassFromCredlyApiBadge); } else if (get(badgeSourceType) == BadgeSourceTypeOptions['Parchment']) { return $parchmentSelectedIssuerBadges.map(badgeclassFromParchmentApiBadge); + } else if (get(badgeSourceType) == BadgeSourceTypeOptions['Accredible']) { + const env = $accredibleSelectedRegion ? accredibleRegions.get($accredibleSelectedRegion) : undefined; + if (!env) return []; + return $accredibleGroups.map((g) => badgeclassFromAccredibleGroup(g, env)); } else { return $advancedBadgesFound; } @@ -287,4 +380,7 @@ export const resetBadgeData = () => { parchmentIssuers.set([]); parchmentSelectedIssuer.set(undefined); parchmentSelectedIssuerBadges.set([]); + + // Does not invalidate accredibleApiKey + accredibleGroups.set([]); }; diff --git a/src/lib/utils/accredible.ts b/src/lib/utils/accredible.ts new file mode 100644 index 0000000..ec6451a --- /dev/null +++ b/src/lib/utils/accredible.ts @@ -0,0 +1,164 @@ +import type { Alignment, BadgeClassCTDLExtended } from '$lib/utils/badges.js'; + +// Accredible Options +// +// Auth header format and the Groups endpoint path/pagination params below are +// taken directly from Accredible's own export script +// (github.com/accredible/accredible-achievement-ob3-export, accredible_ob3_export.py): +// request.add_header("Authorization", f"Token token={self.api_key}") +// self._get("/v1/issuer/all_groups", { page, page_size }) +// +// The exact JSON envelope key for the groups list, and any endpoint for +// fetching a badge design/image, were NOT confirmed against Accredible's API +// reference (docs.accredible.com was not reachable while building this) -- +// see the TODOs below. Confirm both with Accredible before relying on this +// in production. + +export type AccredibleEnvKey = 'us' | 'eu' | 'sandbox'; + +export interface AccredibleEnv { + id: AccredibleEnvKey; + apiDomain: string; + credentialDomain: string; + name: string; +} + +export const accredibleRegions: Map = new Map([ + [ + 'us', + { + id: 'us', + apiDomain: 'https://api.accredible.com', + credentialDomain: 'https://www.credential.net', + name: 'United States (production)' + } + ], + [ + 'eu', + { + id: 'eu', + apiDomain: 'https://eu.api.accredible.com', + credentialDomain: 'https://eu.credential.net', + name: 'Europe (production)' + } + ], + [ + 'sandbox', + { + id: 'sandbox', + apiDomain: 'https://sandbox.api.accredible.com', + credentialDomain: 'https://sandbox.credential.net', + name: 'Sandbox' + } + ] +]); + +// A single "learning outcome" / skill entry as returned by Accredible. Accredible's +// export script emits these as bare names unless run with --resolve-skills, in +// which case framework-matched skills also carry targetUrl/targetFramework/targetCode. +export interface AccredibleLearningOutcome { + name?: string; + targetUrl?: string; + targetFramework?: string; + targetCode?: string; +} + +// Raw shape of one entry from Accredible's `/v1/issuer/all_groups` endpoint, +// limited to the fields Accredible's own export script reads. +export interface AccredibleGroup { + id: number | string; + name?: string; + course_name?: string; + course_description?: string; + description?: string; + earning_criteria?: string; + achievement_type?: string; + learning_outcomes?: Array; + // TODO(confirm with Accredible): if a badge design/image URL is available + // directly on the group payload under some other field name (e.g. + // `design`, `badge_design`, `image_url`), add it here and in + // extractImageFromGroup() below so we can avoid a second API call per badge. +} + +const stripHtml = (html: string): string => { + if (!html) return ''; + return html + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +}; + +// Best-effort attempt to find an already-present image URL on the group +// payload before resorting to a second lookup call. Field names are guesses +// pending confirmation from Accredible -- extend this list once known. +const extractImageFromGroup = (g: AccredibleGroup): string => { + const candidate = + (g as any).image_url || (g as any).badge_design?.image_url || (g as any).design?.image_url; + return typeof candidate === 'string' ? candidate : ''; +}; + +/** + * Converts one Accredible group ("badge template") into the shape Badge + * Publisher's `badgeClassBasicSchema` (src/lib/utils/badges.ts) validates. + * + * IMPORTANT: alignment entries without a resolvable `targetUrl` are dropped + * here rather than passed through with only `targetName`. Badge Publisher's + * importer requires `targetUrl` to be a valid URL on every alignment entry + * and rejects the *entire* achievement if even one entry is missing it, with + * no special case for free-text skills. This was verified against a 316-item + * sample produced by Accredible's export script: 255/316 (81%) failed import + * for exactly this reason, across 799 total alignment entries, all missing + * targetUrl. Dropping unresolvable entries lets the rest of the achievement + * (name, description, criteria) still publish, at the cost of losing the + * unresolved skill names -- which is a reasonable tradeoff since Badge + * Publisher can't accept them as-is anyway. + */ +export const badgeclassFromAccredibleGroup = ( + g: AccredibleGroup, + env: AccredibleEnv, + imageUrl?: string +): BadgeClassCTDLExtended => { + const name = g.course_name || g.name || ''; + const description = stripHtml(g.course_description || g.description || ''); + + const alignment: Alignment[] = (g.learning_outcomes || []) + .map((o): Partial => { + if (typeof o === 'string') return { targetName: o }; + return { + targetName: o.name || '', + targetUrl: o.targetUrl, + targetFramework: o.targetFramework, + targetCode: o.targetCode + }; + }) + .filter((a): a is Alignment => !!a.targetUrl && !!a.targetName); + + return { + id: `${env.credentialDomain}/group/${g.id}`, + name, + description, + image: imageUrl || extractImageFromGroup(g), + issuer: '', + achievementType: g.achievement_type || 'Achievement', + tags: [], + criteria: { + narrative: g.earning_criteria || `See ${env.credentialDomain}/group/${g.id} for details.` + }, + alignment + }; +}; + +export const accredibleAuthHeader = (apiKey: string) => ({ + Name: 'Authorization', + Value: `Token token=${apiKey}` +}); + +// TODO(confirm with Accredible): endpoint for a badge design/credential image, +// keyed by group id. Accredible's export script does not fetch images at all, +// so this endpoint path is NOT verified -- it's a placeholder for whichever +// call Accredible's API reference specifies (possibly a `/v1/credentials` +// lookup filtered by group, or a dedicated design endpoint). Wire this into +// fetchAccredibleGroups() in badgeSourceStore.ts once confirmed; until then, +// image lookup silently falls back to extractImageFromGroup() (usually ''). +export const ACCREDIBLE_GROUP_IMAGE_ENDPOINT_UNCONFIRMED = (env: AccredibleEnv, groupId: string) => + `${env.apiDomain}/v1/issuer/all_groups/${groupId}`; // placeholder -- verify with Accredible From f903659b11a106ccac0b82e28de82372f968df1c Mon Sep 17 00:00:00 2001 From: Jeff Grann Date: Thu, 20 Aug 2026 21:29:57 -0500 Subject: [PATCH 2/3] Fix Accredible badge import and add local-testing login bypass Real fixes to the Accredible badge source: - Add Accredible API regions to the proxy origin whitelist; without them every Accredible fetch was rejected with a 400. - Normalize `earning_criteria`, which Accredible returns as a structured array (not a string); passing it to markdownToTxt() threw and silently dropped the whole credential from the import. - Resolve the badge image from the group Design (design_id -> GET /v1/designs/{id} -> rasterized_content_url), deduped by design_id and best-effort so a lookup failure never breaks the fetch. - Make importCheckedSourceBadges convert per-badge so one malformed badge can no longer wipe the entire drafts list; failures are logged and skipped. - Surface a clear error on a non-JSON (Keycloak) login response instead of spinning forever; abort submit on failed form validation. Dev-only scaffolding (ships disabled): - PUBLIC_DEV_BYPASS_PUBLISHER_LOGIN flag adds a "Skip login (dev)" button that injects a fake publisher session for local testing against Keycloak-migrated Publisher environments. Defaults off in tracked env files. Co-Authored-By: Claude Opus 4.8 --- .env.example | 18 ++- local.env | 5 + src/lib/partials/PublisherConfig.svelte | 146 +++++++++++++++--- src/lib/stores/badgeSourceStore.ts | 59 ++++++- src/lib/stores/publisherStore.ts | 19 ++- src/lib/utils/accredible.ts | 88 +++++++++-- .../publisher/StagingApi/Proxy/+server.ts | 6 +- 7 files changed, 297 insertions(+), 44 deletions(-) diff --git a/.env.example b/.env.example index aefa4dc..13efcd5 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,18 @@ PUBLIC_PUBLISHER_API_ENV_LABEL="(Sandbox)" PUBLIC_UI_API_BASEURL="/publisher" ADAPTER_STATIC="false" +# OPTIONAL, DEV-ONLY: bypass Publisher login for local testing. +# When "true", the login screen shows a "Skip login (dev)" button that injects a +# fake publisher session so you can exercise badge-source workflows (Accredible, +# Canvas, etc.) without a working Publisher login. Publisher environments on +# Keycloak/OIDC (e.g. Sandbox) no longer support the email/password login this +# app uses, so this is the way to test locally against them. +# Leave "false"/unset in any deployed build. Saving to the Publisher still +# requires a real, environment-issued token, so the final save step will fail. +PUBLIC_DEV_BYPASS_PUBLISHER_LOGIN="false" +PUBLIC_DEV_PUBLISHER_ORG_CTID="" # optional: org CTID to attach drafts to; defaults to a placeholder +PUBLIC_DEV_PUBLISHER_ORG_NAME="" # optional: display name for the fake org + # OPTIONAL Canvas Login Settings per environment: only for use on production. # It is OK that client secret is readable by the user, because the exchange is # protected with PKCE and exact-match redirect URIs set by Canvas admins. @@ -48,4 +60,8 @@ PUBLIC_PARCHMENT_EU_LOGIN_CLIENT_SECRET="" PUBLIC_PARCHMENT_US_ENABLED="true" PUBLIC_PARCHMENT_US_LOGIN_CLIENT_ID="" -PUBLIC_PARCHMENT_US_LOGIN_CLIENT_SECRET="" \ No newline at end of file +PUBLIC_PARCHMENT_US_LOGIN_CLIENT_SECRET="" + +PUBLIC_PARCHMENT_SG_ENABLED="true" +PUBLIC_PARCHMENT_SG_LOGIN_CLIENT_ID="" +PUBLIC_PARCHMENT_SG_LOGIN_CLIENT_SECRET="" \ No newline at end of file diff --git a/local.env b/local.env index be7bb71..818a9ed 100644 --- a/local.env +++ b/local.env @@ -3,5 +3,10 @@ PUBLIC_PUBLISHER_API_BASEURL="https://localhost:44330/" PUBLIC_PUBLISHER_API_ENV_LABEL="(local)" PUBLIC_UI_API_BASEURL="/" ADAPTER_STATIC="false" + +# DEV-ONLY: bypass Publisher login for local testing (see .env.example for details). +PUBLIC_DEV_BYPASS_PUBLISHER_LOGIN="false" +PUBLIC_DEV_PUBLISHER_ORG_CTID="" # optional: org CTID to attach drafts to; defaults to a placeholder +PUBLIC_DEV_PUBLISHER_ORG_NAME="" # optional: display name for the fake org PUBLIC_CANVAS_TEST_LOGIN_CLIENT_ID="" PUBLIC_CANVAS_TEST_LOGIN_CLIENT_SECRET="" diff --git a/src/lib/partials/PublisherConfig.svelte b/src/lib/partials/PublisherConfig.svelte index 2b75bb4..96ce627 100644 --- a/src/lib/partials/PublisherConfig.svelte +++ b/src/lib/partials/PublisherConfig.svelte @@ -16,6 +16,8 @@ PUBLIC_PUBLISHER_API_BASEURL, PUBLIC_PUBLISHER_API_ENV_LABEL } from '$env/static/public'; + // Optional, dev-only settings. Read via dynamic env so builds don't break when unset. + import { env as publicDynamicEnv } from '$env/dynamic/public'; import { getUser, publisherUser, @@ -68,40 +70,122 @@ return; }) .then(async (valid) => { + // If validation failed, the .catch above resolves to undefined; abort here + // so we don't fire a login request (or spin) with invalid input. + if (!valid) return; + userIsLoading = true; const url = `${PUBLIC_UI_API_BASEURL}/StagingApi/Login`; - const response = await fetch(url, { - method: 'POST', - body: JSON.stringify(formData), - headers: { - 'Content-Type': 'application/json' - } - }); - const responseData = await response.json(); - if (!responseData['Valid']) { - let errorMessage: string; + try { + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(formData), + headers: { + 'Content-Type': 'application/json' + } + }); + + // The Publisher may respond with a non-JSON body (e.g. an HTML Keycloak + // sign-in page) once an environment migrates to interactive OIDC login. + // Parsing that as JSON used to throw silently and leave the spinner running + // forever, so handle it explicitly with a helpful message. + let responseData: any; try { - errorMessage = responseData.Messages[0] || responseData.message; + responseData = await response.json(); } catch { - errorMessage = 'Unexpected server error!'; + setAlert( + 'error', + `The Publisher did not return a valid login response (HTTP ${response.status}). ` + + `This environment likely requires interactive sign-in (Keycloak) and no longer ` + + `supports email/password login from this app.`, + 'Authentication error:' + ); + userIsLoading = false; + return; + } + + if (!responseData['Valid']) { + let errorMessage: string; + try { + errorMessage = responseData.Messages[0] || responseData.message; + } catch { + errorMessage = 'Unexpected server error!'; + } + + setAlert('error', errorMessage, 'Authentication error:'); + userIsLoading = false; + return; } - setAlert('error', errorMessage, 'Authentication error:'); + // reset form and save user + registryEmailAddress = ''; + registryPassword = ''; + registryAgreeTerms = false; + publisherUser.set({ user: responseData['Data'] }); + userIsLoading = false; + $publisherSetupStep = 2; + refreshCredentialTypes(); + } catch (e) { + setAlert( + 'error', + `Could not reach the Publisher login service: ${String(e)}`, + 'Authentication error:' + ); userIsLoading = false; - return; } - - // reset form and save user - registryEmailAddress = ''; - registryPassword = ''; - registryAgreeTerms = false; - publisherUser.set({ user: responseData['Data'] }); - userIsLoading = false; - $publisherSetupStep = 2; - refreshCredentialTypes(); }); }; + // --------------------------------------------------------------------------- + // DEV-ONLY: skip Publisher login for local testing. + // + // The email/password login above only works against Publisher environments + // that still support it. Environments on Keycloak/OIDC (e.g. Sandbox) return + // an HTML sign-in page instead of a token, which blocks local testing of the + // badge-source workflows (Accredible, Canvas, etc.). + // + // When PUBLIC_DEV_BYPASS_PUBLISHER_LOGIN is "true", we inject a fake publisher + // session (user + selected org + placeholder verification service) directly + // into the stores and jump straight to the completed state. This lets you + // exercise everything up to (but not including) the real "Save to Publisher" + // step, whose API calls still require a valid, environment-issued token. + // + // This is gated on an env flag that ships disabled; it is a no-op in prod. + // --------------------------------------------------------------------------- + const devBypassEnabled = publicDynamicEnv.PUBLIC_DEV_BYPASS_PUBLISHER_LOGIN === 'true'; + const devOrgCtid = + publicDynamicEnv.PUBLIC_DEV_PUBLISHER_ORG_CTID || + 'ce-00000000-0000-0000-0000-000000000000'; + const devOrgName = publicDynamicEnv.PUBLIC_DEV_PUBLISHER_ORG_NAME || 'Dev Test Organization'; + + const devBypassLogin = () => { + resetAlert(); + const fakeOrg = { + Id: '0', + RowId: '00000000-0000-0000-0000-000000000000', + Name: devOrgName, + CTID: devOrgCtid, + Type: 'Organization' + }; + publisherUser.set({ + user: { + Id: 0, + Name: 'Dev Tester', + Email: 'dev@example.com', + IsSiteStaff: false, + Token: 'DEV_FAKE_TOKEN', + Organizations: [fakeOrg] + } + }); + publisherOrganization.set({ org: fakeOrg }); + publisherVerificationService.set(devOrgCtid); + // Credentials list stays empty; we skip the API-backed preview entirely. + panelIsHidden = true; + $publisherSetupStep = 4; + if ($badgeSetupStep == 0) $badgeSetupStep = 1; + refreshCredentialTypes(); + }; + const publisherUrl = new URL(PUBLIC_PUBLISHER_API_BASEURL); const accountSettingsUrl = new URL('/accounts/Dashboard', publisherUrl.origin).href; let userPromise = new Promise((resolve, reject) => {}); // use await block to show loading spinner to start @@ -270,6 +354,22 @@
+ + {#if devBypassEnabled} +
+ + Developer mode: skip Publisher login + and inject a fake session (org + {devOrgCtid}) so you can test badge-source + workflows locally. Saving to the Publisher will still require a real account. + +
+ +
+
+ {/if} {:else if userIsLoading}
diff --git a/src/lib/stores/badgeSourceStore.ts b/src/lib/stores/badgeSourceStore.ts index 11817d6..5bfe51f 100644 --- a/src/lib/stores/badgeSourceStore.ts +++ b/src/lib/stores/badgeSourceStore.ts @@ -13,8 +13,10 @@ import { publisherUser } from '$lib/stores/publisherStore.js'; import { badgeclassFromParchmentApiBadge, type ParchmentBadge, type ParchmentEnvKey, type ParchmentIssuer, parchmentRegions } from '$lib/utils/parchment.js'; import { accredibleAuthHeader, + accredibleDesignEndpoint, accredibleRegions, badgeclassFromAccredibleGroup, + imageUrlFromDesign, type AccredibleEnvKey, type AccredibleGroup } from '$lib/utils/accredible.js'; @@ -225,8 +227,19 @@ export const fetchAccredibleGroups = async (): Promise => { }); const proxyResponseData = await proxyResponse.json(); - if (!proxyResponseData.Valid || proxyResponseData.Data?.StatusCode != '200') - throw new Error('Error fetching group data from Accredible.'); + if (!proxyResponseData.Valid || proxyResponseData.Data?.StatusCode != '200') { + const status = proxyResponseData.Data?.StatusCode ?? proxyResponseData.StatusCode; + const detail = + proxyResponseData.Data?.Body || proxyResponseData.StatusMessage || 'no response body'; + const hint = + status == 401 || status == 403 + ? ' Check that the API key is correct and matches the selected region.' + : ''; + throw new Error( + `Error fetching group data from Accredible (status ${status ?? 'unknown'}).${hint} ` + + `Details: ${String(detail).slice(0, 300)}` + ); + } const body = JSON.parse(proxyResponseData.Data?.Body); // NOTE: the exact envelope key returned by this endpoint was not confirmed @@ -241,6 +254,48 @@ export const fetchAccredibleGroups = async (): Promise => { if (page > 200) break; // safety valve against an unexpected infinite loop } + // Best-effort: resolve a badge image for each group from its Design. + // The group payload carries no image, only a `design_id`; the Design's + // `rasterized_content_url` is a rendered image of the badge. We dedupe by + // design_id (groups commonly share designs) to minimize calls, and never + // let an image lookup failure break the overall fetch. + const designIds = [ + ...new Set( + allGroups + .map((g) => g.design_id) + .filter((id): id is number | string => id !== undefined && id !== null && id !== '') + ) + ]; + const designImageById = new Map(); + for (const designId of designIds) { + try { + const designRequest = { + URL: accredibleDesignEndpoint(env, designId), + Method: 'GET', + Body: null, + Headers: [accredibleAuthHeader(apiKey), { Name: 'Accept', Value: 'application/json' }] + }; + const designResponse = await fetch(`${PUBLIC_UI_API_BASEURL}/StagingApi/Proxy`, { + method: 'POST', + body: JSON.stringify(designRequest), + headers: proxyRequestHeaders + }); + const designResponseData = await designResponse.json(); + if (designResponseData.Valid && designResponseData.Data?.StatusCode == '200') { + const img = imageUrlFromDesign(JSON.parse(designResponseData.Data.Body)); + if (img) designImageById.set(String(designId), img); + } + } catch (e) { + console.warn(`Could not load Accredible design ${designId} for a badge image:`, e); + } + } + allGroups.forEach((g) => { + if (!g.image_url && g.design_id != null) { + const img = designImageById.get(String(g.design_id)); + if (img) g.image_url = img; + } + }); + accredibleGroups.set(allGroups); return true; }; diff --git a/src/lib/stores/publisherStore.ts b/src/lib/stores/publisherStore.ts index afd0fcd..4a9e6ff 100644 --- a/src/lib/stores/publisherStore.ts +++ b/src/lib/stores/publisherStore.ts @@ -1330,12 +1330,19 @@ const createCredentialDraftStore = () => { subscribe, importCheckedSourceBadges: () => { const checkedBadgeKeys = get(checkedBadges); - set( - get(normalizedBadges) - .filter((b) => checkedBadgeKeys[b.id] === true) - .map((bc) => badgeClassToCtdlApiCredential(bc)) - .sort((a, b) => a.Credential.Name.localeCompare(b.Credential.Name)) - ); + const drafts: CtdlCredentialDraft[] = []; + get(normalizedBadges) + .filter((b) => checkedBadgeKeys[b.id] === true) + .forEach((bc) => { + // Convert per-badge so one malformed badge can't abort the whole + // import (which would silently leave the drafts list empty). + try { + drafts.push(badgeClassToCtdlApiCredential(bc)); + } catch (e) { + console.error(`Failed to import badge "${bc.name}" (${bc.id}):`, e); + } + }); + set(drafts.sort((a, b) => a.Credential.Name.localeCompare(b.Credential.Name))); }, updateCredential: (b: CtdlCredentialDraft) => { update((credentialList) => { diff --git a/src/lib/utils/accredible.ts b/src/lib/utils/accredible.ts index ec6451a..efa3bdf 100644 --- a/src/lib/utils/accredible.ts +++ b/src/lib/utils/accredible.ts @@ -63,6 +63,18 @@ export interface AccredibleLearningOutcome { targetCode?: string; } +// A single earning-criterion entry as returned by Accredible. Sandbox groups +// return `earning_criteria` as an array of these objects (each `text` is an +// HTML fragment), NOT as a plain string. Confirmed against a live sandbox +// probe (see accredible_probe.py output). +export interface AccredibleCriterion { + id?: string; + kind?: string; // e.g. "degree", "skill", "completion" + text?: string; // HTML fragment describing the criterion + required?: boolean; + position?: number; +} + // Raw shape of one entry from Accredible's `/v1/issuer/all_groups` endpoint, // limited to the fields Accredible's own export script reads. export interface AccredibleGroup { @@ -71,8 +83,17 @@ export interface AccredibleGroup { course_name?: string; course_description?: string; description?: string; - earning_criteria?: string; + // May be a plain string OR a structured array of criterion objects, + // depending on how the group's criteria were configured in Accredible. + earning_criteria?: string | AccredibleCriterion[]; achievement_type?: string; + // Groups render credentials using a reusable Design, referenced by id. The + // group payload has no image itself; the badge image comes from the Design + // (see fetchAccredibleGroups() / GET /v1/designs/{design_id}). + design_id?: number | string; + // Populated by fetchAccredibleGroups() after resolving the Design, so + // extractImageFromGroup() below can pick it up. Not returned by Accredible. + image_url?: string; learning_outcomes?: Array; // TODO(confirm with Accredible): if a badge design/image URL is available // directly on the group payload under some other field name (e.g. @@ -88,6 +109,31 @@ const stripHtml = (html: string): string => { .trim(); }; +// Normalize Accredible's `earning_criteria` (which may be a plain string or a +// structured array of criterion objects) into a single narrative string. +// Downstream code (badgeClassToCtdlApiCredential) runs this through +// markdownToTxt(), which requires a string -- passing the raw array throws and +// silently drops the whole credential from the import. +const narrativeFromEarningCriteria = ( + earning: string | AccredibleCriterion[] | undefined, + fallback: string +): string => { + if (!earning) return fallback; + if (typeof earning === 'string') return earning; + if (Array.isArray(earning)) { + const parts = [...earning] + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) + .map((c) => { + const text = stripHtml(c.text || ''); + if (!text) return ''; + return c.required === false ? `${text} (optional)` : text; + }) + .filter((t) => t.length > 0); + return parts.length ? parts.join('\n\n') : fallback; + } + return fallback; +}; + // Best-effort attempt to find an already-present image URL on the group // payload before resorting to a second lookup call. Field names are guesses // pending confirmation from Accredible -- extend this list once known. @@ -142,7 +188,10 @@ export const badgeclassFromAccredibleGroup = ( achievementType: g.achievement_type || 'Achievement', tags: [], criteria: { - narrative: g.earning_criteria || `See ${env.credentialDomain}/group/${g.id} for details.` + narrative: narrativeFromEarningCriteria( + g.earning_criteria, + `See ${env.credentialDomain}/group/${g.id} for details.` + ) }, alignment }; @@ -153,12 +202,29 @@ export const accredibleAuthHeader = (apiKey: string) => ({ Value: `Token token=${apiKey}` }); -// TODO(confirm with Accredible): endpoint for a badge design/credential image, -// keyed by group id. Accredible's export script does not fetch images at all, -// so this endpoint path is NOT verified -- it's a placeholder for whichever -// call Accredible's API reference specifies (possibly a `/v1/credentials` -// lookup filtered by group, or a dedicated design endpoint). Wire this into -// fetchAccredibleGroups() in badgeSourceStore.ts once confirmed; until then, -// image lookup silently falls back to extractImageFromGroup() (usually ''). -export const ACCREDIBLE_GROUP_IMAGE_ENDPOINT_UNCONFIRMED = (env: AccredibleEnv, groupId: string) => - `${env.apiDomain}/v1/issuer/all_groups/${groupId}`; // placeholder -- verify with Accredible +// A Design object as returned by GET /v1/designs/{design_id}. Only the +// image-bearing fields are modeled here. `rasterized_content_url` is +// Accredible's documented "link to generate an image of the design"; the other +// keys are tolerated as fallbacks in case the account returns a different shape. +export interface AccredibleDesign { + id?: number | string; + kind?: string; // 'badge' | 'certificate' + rasterized_content_url?: string; + image_url?: string; + preview_url?: string; +} + +// Endpoint for a single Design. A group's `design_id` points here; the Design's +// rasterized image is used as the badge image. +export const accredibleDesignEndpoint = (env: AccredibleEnv, designId: string | number) => + `${env.apiDomain}/v1/designs/${designId}`; + +// Extract a usable image URL from a design payload, tolerating either a bare +// design object or one wrapped under a `design` key, and a few field-name +// variants. Returns '' when nothing usable is present. +export const imageUrlFromDesign = (payload: unknown): string => { + const root = (payload ?? {}) as Record; + const d: Record = root.design ?? root; + const candidate = d.rasterized_content_url || d.image_url || d.preview_url; + return typeof candidate === 'string' ? candidate : ''; +}; diff --git a/src/routes/publisher/StagingApi/Proxy/+server.ts b/src/routes/publisher/StagingApi/Proxy/+server.ts index 10267c0..af12615 100644 --- a/src/routes/publisher/StagingApi/Proxy/+server.ts +++ b/src/routes/publisher/StagingApi/Proxy/+server.ts @@ -7,7 +7,11 @@ const ORIGIN_WHITELIST = [ 'https://api.eu.badgr.io', 'https://api.ca.badgr.io', 'https://api.test.badgr.com', - 'https://www.credly.com' + 'https://www.credly.com', + // Accredible API regions (must match apiDomain values in src/lib/utils/accredible.ts) + 'https://api.accredible.com', + 'https://eu.api.accredible.com', + 'https://sandbox.api.accredible.com' ]; export const POST: RequestHandler = async ({ request }) => { From 6ab12ed8219b787a0d1efcdc4719837f409fd651 Mon Sep 17 00:00:00 2001 From: Jeff Grann Date: Fri, 21 Aug 2026 09:20:57 -0500 Subject: [PATCH 3/3] Render Accredible badge image with the group's design and name The group payload carries no image, only design-id references, and the design's rasterized image is a blank template. Two fixes so the imported badge matches what Accredible renders: - Select the badge-specific design. A group exposes several design ids; badgeDesignIdForGroup() prefers badge_design_id, then primary_design_id, then design_id (certificate_design_id last). Using design_id alone often resolved the wrong design or none, yielding a placeholder. - Merge the group's name into the render. POST /v1/designs/{id}/preview with group.course_name / group.name returns the populated badge as { link }; fall back to the blank rasterized image only if that fails. Image resolution is now per-group (cached by design+name) since the render depends on the name. Co-Authored-By: Claude Opus 4.8 --- src/lib/stores/badgeSourceStore.ts | 93 +++++++++++++++++++----------- src/lib/utils/accredible.ts | 45 ++++++++++++--- 2 files changed, 95 insertions(+), 43 deletions(-) diff --git a/src/lib/stores/badgeSourceStore.ts b/src/lib/stores/badgeSourceStore.ts index 5bfe51f..f40ac27 100644 --- a/src/lib/stores/badgeSourceStore.ts +++ b/src/lib/stores/badgeSourceStore.ts @@ -14,8 +14,10 @@ import { badgeclassFromParchmentApiBadge, type ParchmentBadge, type ParchmentEnv import { accredibleAuthHeader, accredibleDesignEndpoint, + accredibleDesignPreviewEndpoint, accredibleRegions, badgeclassFromAccredibleGroup, + badgeDesignIdForGroup, imageUrlFromDesign, type AccredibleEnvKey, type AccredibleGroup @@ -255,46 +257,69 @@ export const fetchAccredibleGroups = async (): Promise => { } // Best-effort: resolve a badge image for each group from its Design. - // The group payload carries no image, only a `design_id`; the Design's - // `rasterized_content_url` is a rendered image of the badge. We dedupe by - // design_id (groups commonly share designs) to minimize calls, and never - // let an image lookup failure break the overall fetch. - const designIds = [ - ...new Set( - allGroups - .map((g) => g.design_id) - .filter((id): id is number | string => id !== undefined && id !== null && id !== '') - ) - ]; - const designImageById = new Map(); - for (const designId of designIds) { + // The group payload carries no image, only design ids; for a badge we use + // `badge_design_id` (falling back to the primary/default design). An image + // lookup failure is logged and never breaks the overall fetch. + const proxyRequest = async ( + method: string, + url: string, + body: string | null = null + ): Promise => { + const response = await fetch(`${PUBLIC_UI_API_BASEURL}/StagingApi/Proxy`, { + method: 'POST', + body: JSON.stringify({ + URL: url, + Method: method, + Body: body, + Headers: [ + accredibleAuthHeader(apiKey), + { Name: 'Accept', Value: 'application/json' }, + { Name: 'Content-Type', Value: 'application/json' } + ] + }), + headers: proxyRequestHeaders + }); + const data = await response.json(); + if (data.Valid && data.Data?.StatusCode == '200') return JSON.parse(data.Data.Body); + return null; + }; + + // The design's rasterized image is a BLANK template (no data merged), so to + // match how Accredible renders the badge we POST to the design /preview + // endpoint with the group's display name merged in (`group.course_name` / + // `group.name`), which returns a rendered `{ link }`. We fall back to the + // blank rasterized image only if the merged render fails. Because the merged + // image depends on the name, it's per-group; we cache by design+name since + // groups can share a design and name. + const imageCache = new Map(); + for (const g of allGroups) { + if (g.image_url) continue; + const designId = badgeDesignIdForGroup(g); + if (designId === undefined) continue; + const name = g.course_name || g.name || ''; + const cacheKey = `${designId}|${name}`; + if (imageCache.has(cacheKey)) { + g.image_url = imageCache.get(cacheKey); + continue; + } try { - const designRequest = { - URL: accredibleDesignEndpoint(env, designId), - Method: 'GET', - Body: null, - Headers: [accredibleAuthHeader(apiKey), { Name: 'Accept', Value: 'application/json' }] - }; - const designResponse = await fetch(`${PUBLIC_UI_API_BASEURL}/StagingApi/Proxy`, { - method: 'POST', - body: JSON.stringify(designRequest), - headers: proxyRequestHeaders - }); - const designResponseData = await designResponse.json(); - if (designResponseData.Valid && designResponseData.Data?.StatusCode == '200') { - const img = imageUrlFromDesign(JSON.parse(designResponseData.Data.Body)); - if (img) designImageById.set(String(designId), img); + // Render the design with the group's name merged in. + const previewBody = JSON.stringify({ 'group.course_name': name, 'group.name': name }); + let img = imageUrlFromDesign( + await proxyRequest('POST', accredibleDesignPreviewEndpoint(env, designId), previewBody) + ); + if (!img) { + // Fallback: the design's blank rasterized image (no name merged). + img = imageUrlFromDesign(await proxyRequest('GET', accredibleDesignEndpoint(env, designId))); + } + if (img) { + imageCache.set(cacheKey, img); + g.image_url = img; } } catch (e) { console.warn(`Could not load Accredible design ${designId} for a badge image:`, e); } } - allGroups.forEach((g) => { - if (!g.image_url && g.design_id != null) { - const img = designImageById.get(String(g.design_id)); - if (img) g.image_url = img; - } - }); accredibleGroups.set(allGroups); return true; diff --git a/src/lib/utils/accredible.ts b/src/lib/utils/accredible.ts index efa3bdf..1bed18d 100644 --- a/src/lib/utils/accredible.ts +++ b/src/lib/utils/accredible.ts @@ -87,10 +87,15 @@ export interface AccredibleGroup { // depending on how the group's criteria were configured in Accredible. earning_criteria?: string | AccredibleCriterion[]; achievement_type?: string; - // Groups render credentials using a reusable Design, referenced by id. The - // group payload has no image itself; the badge image comes from the Design - // (see fetchAccredibleGroups() / GET /v1/designs/{design_id}). + // Groups render credentials using reusable Designs, referenced by id. The + // group payload has no image itself; the badge image comes from a Design + // (see fetchAccredibleGroups() / GET /v1/designs/{design_id}). A group can + // carry several design ids -- for a badge we want `badge_design_id`. design_id?: number | string; + badge_design_id?: number | string; + certificate_design_id?: number | string; + primary_design_id?: number | string; + design_name?: string; // Populated by fetchAccredibleGroups() after resolving the Design, so // extractImageFromGroup() below can pick it up. Not returned by Accredible. image_url?: string; @@ -214,17 +219,39 @@ export interface AccredibleDesign { preview_url?: string; } -// Endpoint for a single Design. A group's `design_id` points here; the Design's -// rasterized image is used as the badge image. +// The design a group uses for its BADGE image. A group carries several design +// ids; prefer the badge-specific one, then the group's primary/default design. +// (certificate_design_id is intentionally last -- it renders a certificate, not +// a badge.) Returns undefined when the group references no usable design. +export const badgeDesignIdForGroup = (g: AccredibleGroup): number | string | undefined => { + for (const candidate of [ + g.badge_design_id, + g.primary_design_id, + g.design_id, + g.certificate_design_id + ]) { + if (candidate !== undefined && candidate !== null && candidate !== '') return candidate; + } + return undefined; +}; + +// Endpoint for a single Design. A group's badge design id points here; the +// Design's rasterized image is used as the badge image. export const accredibleDesignEndpoint = (env: AccredibleEnv, designId: string | number) => `${env.apiDomain}/v1/designs/${designId}`; -// Extract a usable image URL from a design payload, tolerating either a bare -// design object or one wrapped under a `design` key, and a few field-name -// variants. Returns '' when nothing usable is present. +// Endpoint that renders a preview image of a Design and returns `{ link }`. +// Used as a fallback when the Design object itself has no rasterized image URL. +export const accredibleDesignPreviewEndpoint = (env: AccredibleEnv, designId: string | number) => + `${env.apiDomain}/v1/designs/${designId}/preview`; + +// Extract a usable image URL from a design or design-preview payload, tolerating +// either a bare object or one wrapped under a `design` key, and a few field-name +// variants (`rasterized_content_url` from GET design, `link` from the preview +// endpoint). Returns '' when nothing usable is present. export const imageUrlFromDesign = (payload: unknown): string => { const root = (payload ?? {}) as Record; const d: Record = root.design ?? root; - const candidate = d.rasterized_content_url || d.image_url || d.preview_url; + const candidate = d.rasterized_content_url || d.image_url || d.preview_url || d.link; return typeof candidate === 'string' ? candidate : ''; };