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/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.
+
+
+
+
+ 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/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 1fdd16a..f40ac27 100644
--- a/src/lib/stores/badgeSourceStore.ts
+++ b/src/lib/stores/badgeSourceStore.ts
@@ -11,9 +11,21 @@ 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,
+ accredibleDesignEndpoint,
+ accredibleDesignPreviewEndpoint,
+ accredibleRegions,
+ badgeclassFromAccredibleGroup,
+ badgeDesignIdForGroup,
+ imageUrlFromDesign,
+ type AccredibleEnvKey,
+ type AccredibleGroup
+} from '$lib/utils/accredible.js';
export enum BadgeSourceTypeOptions {
None = '',
+ Accredible = 'accredible',
Canvas = 'canvas',
Credly = 'credly',
JSON = 'json',
@@ -175,6 +187,144 @@ 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') {
+ 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
+ // 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
+ }
+
+ // Best-effort: resolve a badge image for each group from its Design.
+ // 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 {
+ // 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);
+ }
+ }
+
+ accredibleGroups.set(allGroups);
+ return true;
+};
+
// Advanced JSON setup
export const advancedBadges = writable>([]);
export const advancedBadgesFound = derived(
@@ -196,7 +346,11 @@ export const badgeSetupComplete = derived(
parchmentAgreeTerms,
parchmentSelectedRegion,
parchmentSelectedIssuer,
- parchmentOrganization
+ parchmentOrganization,
+ accredibleApiKey,
+ accredibleAgreeTerms,
+ accredibleSelectedRegion,
+ accredibleGroups
],
([
$advancedBadgesFound,
@@ -209,7 +363,11 @@ export const badgeSetupComplete = derived(
$parchmentAgreeTerms,
$parchmentSelectedRegion,
$parchmentSelectedIssuer,
- $parchmentOrganization
+ $parchmentOrganization,
+ $accredibleApiKey,
+ $accredibleAgreeTerms,
+ $accredibleSelectedRegion,
+ $accredibleGroups
]) => {
if ($badgeSourceType == BadgeSourceTypeOptions['Credly']) {
return (
@@ -226,6 +384,13 @@ export const badgeSetupComplete = derived(
!!$parchmentSelectedIssuer &&
!!$parchmentOrganization
);
+ } else if ($badgeSourceType == BadgeSourceTypeOptions['Accredible']) {
+ return (
+ !!$accredibleApiKey &&
+ !!$accredibleAgreeTerms &&
+ !!$accredibleSelectedRegion &&
+ !!$accredibleGroups.length
+ );
} else {
return !!$advancedBadgesFound.length;
}
@@ -242,6 +407,8 @@ export const normalizedBadges: Readable = derived(
canvasSelectedIssuerBadges,
credlyIssuerBadges,
parchmentSelectedIssuerBadges,
+ accredibleGroups,
+ accredibleSelectedRegion,
advancedBadgesFound
],
([
@@ -250,6 +417,8 @@ export const normalizedBadges: Readable = derived(
$canvasSelectedIssuerBadges,
$credlyIssuerBadges,
$parchmentSelectedIssuerBadges,
+ $accredibleGroups,
+ $accredibleSelectedRegion,
$advancedBadgesFound
]) => {
if (!$badgeSetupComplete) {
@@ -263,6 +432,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 +460,7 @@ export const resetBadgeData = () => {
parchmentIssuers.set([]);
parchmentSelectedIssuer.set(undefined);
parchmentSelectedIssuerBadges.set([]);
+
+ // Does not invalidate accredibleApiKey
+ accredibleGroups.set([]);
};
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
new file mode 100644
index 0000000..1bed18d
--- /dev/null
+++ b/src/lib/utils/accredible.ts
@@ -0,0 +1,257 @@
+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;
+}
+
+// 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 {
+ id: number | string;
+ name?: string;
+ course_name?: string;
+ course_description?: string;
+ description?: 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 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;
+ 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();
+};
+
+// 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.
+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: narrativeFromEarningCriteria(
+ g.earning_criteria,
+ `See ${env.credentialDomain}/group/${g.id} for details.`
+ )
+ },
+ alignment
+ };
+};
+
+export const accredibleAuthHeader = (apiKey: string) => ({
+ Name: 'Authorization',
+ Value: `Token token=${apiKey}`
+});
+
+// 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;
+}
+
+// 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}`;
+
+// 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 || d.link;
+ 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 }) => {