Add Joystick as a confidential, rotating managed-connection provider
Joystick joins the catalog (Basic client auth, rotatesRefreshTokens), resolves identity by channel id, and is a streampixels managed-connection provider only: it is not an identity provider for the profile (ruling signin-waits-for-public-bot). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c81f923890
commit
1c38d12050
14 changed files with 212 additions and 18 deletions
|
|
@ -4,7 +4,7 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["provider", "mode", "returnTo", "entitlementPolicy"],
|
"required": ["provider", "mode", "returnTo", "entitlementPolicy"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify"] },
|
"provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] },
|
||||||
"mode": { "enum": ["sign_in", "link", "connect"] },
|
"mode": { "enum": ["sign_in", "link", "connect"] },
|
||||||
"returnTo": { "type": "string", "format": "uri" },
|
"returnTo": { "type": "string", "format": "uri" },
|
||||||
"entitlementPolicy": {
|
"entitlementPolicy": {
|
||||||
|
|
|
||||||
|
|
@ -99,10 +99,10 @@ const repixelizerProfile: AppProfile = {
|
||||||
const streampixelsProfile: AppProfile = {
|
const streampixelsProfile: AppProfile = {
|
||||||
slug: "streampixels",
|
slug: "streampixels",
|
||||||
displayName: "StreamPixels",
|
displayName: "StreamPixels",
|
||||||
profileVersion: "2026-04-26",
|
profileVersion: "2026-10-19",
|
||||||
identityProviders: ["twitch", "youtube"],
|
identityProviders: ["twitch", "youtube"],
|
||||||
entitlementSources: [],
|
entitlementSources: [],
|
||||||
managedConnectionProviders: ["twitch", "youtube"],
|
managedConnectionProviders: ["twitch", "youtube", "joystick"],
|
||||||
allowedReturnOrigins: ["https://streampixels.gamecult.org"],
|
allowedReturnOrigins: ["https://streampixels.gamecult.org"],
|
||||||
capabilities: [
|
capabilities: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify"] as const;
|
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const;
|
||||||
|
|
||||||
export type Provider = (typeof providers)[number];
|
export type Provider = (typeof providers)[number];
|
||||||
|
|
||||||
|
|
|
||||||
49
src/oauth.ts
49
src/oauth.ts
|
|
@ -513,6 +513,45 @@ async function resolveSpotifyIdentity(options: { accessToken: string }): Promise
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveJoystickIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
|
||||||
|
const profile = await fetchJson<{
|
||||||
|
channel_id?: string;
|
||||||
|
username?: string;
|
||||||
|
nickname?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
}>(
|
||||||
|
"https://api.joystick.tv/api/v1/me/identity",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${options.accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Joystick identity lookup failed"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!profile.channel_id) {
|
||||||
|
throw new Error("Joystick identity response did not include a channel id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity: ResolvedIdentity = {
|
||||||
|
provider: "joystick",
|
||||||
|
providerUserId: profile.channel_id,
|
||||||
|
profile: profile as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
const displayName = profile.nickname ?? profile.username;
|
||||||
|
if (profile.username) {
|
||||||
|
identity.username = profile.username;
|
||||||
|
}
|
||||||
|
if (displayName) {
|
||||||
|
identity.displayName = displayName;
|
||||||
|
}
|
||||||
|
if (profile.photo_url) {
|
||||||
|
identity.avatarUrl = profile.photo_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
async function evaluateDiscordEntitlements(options: {
|
async function evaluateDiscordEntitlements(options: {
|
||||||
config: HeimdallConfig;
|
config: HeimdallConfig;
|
||||||
callback: OAuthCallbackContext;
|
callback: OAuthCallbackContext;
|
||||||
|
|
@ -860,6 +899,15 @@ const spotifyRuntime: OAuthProviderRuntime = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const joystickRuntime: OAuthProviderRuntime = {
|
||||||
|
exchangeAuthorizationCode: sharedRuntimeExchange("joystick"),
|
||||||
|
refreshAccessToken: sharedRuntimeRefresh("joystick"),
|
||||||
|
resolveIdentity: resolveJoystickIdentity,
|
||||||
|
async evaluateEntitlements() {
|
||||||
|
return { facts: [], snapshots: [] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const notImplementedRuntime: OAuthProviderRuntime = {
|
const notImplementedRuntime: OAuthProviderRuntime = {
|
||||||
async exchangeAuthorizationCode() {
|
async exchangeAuthorizationCode() {
|
||||||
throw new Error("Provider callback exchange is not implemented yet.");
|
throw new Error("Provider callback exchange is not implemented yet.");
|
||||||
|
|
@ -882,5 +930,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
|
||||||
twitch: twitchRuntime,
|
twitch: twitchRuntime,
|
||||||
youtube: youtubeRuntime,
|
youtube: youtubeRuntime,
|
||||||
spotify: spotifyRuntime,
|
spotify: spotifyRuntime,
|
||||||
|
joystick: joystickRuntime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,18 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
||||||
clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID",
|
clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID",
|
||||||
clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE",
|
clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE",
|
||||||
},
|
},
|
||||||
|
joystick: {
|
||||||
|
key: "joystick",
|
||||||
|
displayName: "Joystick",
|
||||||
|
authorizationEndpoint: "https://joystick.tv/api/oauth/authorize",
|
||||||
|
tokenEndpoint: "https://api.joystick.tv/api/oauth/token",
|
||||||
|
tokenEndpointAuthMethod: "client_secret_basic",
|
||||||
|
rotatesRefreshTokens: true,
|
||||||
|
defaultScopes: ["identity:read", "stream:read", "chat:read", "followers:read", "subscribers:read"],
|
||||||
|
roles: ["identity", "managed_credential"],
|
||||||
|
clientIdEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
|
||||||
|
clientSecretEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildAuthorizationUrl(options: {
|
export function buildAuthorizationUrl(options: {
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,7 @@ function testConfig(): HeimdallConfig {
|
||||||
storage: { backend: "memory", applySchemaOnStartup: true },
|
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||||
providers: {
|
providers: {
|
||||||
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||||
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {},
|
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ function createTestConfig(): HeimdallConfig {
|
||||||
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
|
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
|
||||||
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
||||||
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
|
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
|
||||||
|
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -974,7 +975,8 @@ describe("Heimdall service", () => {
|
||||||
expect(profileResponse.json()).toEqual(
|
expect(profileResponse.json()).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
slug: "streampixels",
|
slug: "streampixels",
|
||||||
managedConnectionProviders: ["twitch", "youtube"],
|
identityProviders: ["twitch", "youtube"],
|
||||||
|
managedConnectionProviders: ["twitch", "youtube", "joystick"],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -1349,6 +1351,109 @@ describe("Heimdall service", () => {
|
||||||
expect(urls).toEqual([]);
|
expect(urls).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("joystick connect", () => {
|
||||||
|
async function connect(app: FastifyInstance) {
|
||||||
|
const start = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/v1/oauth/joystick/start",
|
||||||
|
payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect" },
|
||||||
|
});
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("starts a streampixels connect at joystick.tv with the five scopes and the callback redirect", async () => {
|
||||||
|
const app = await buildApp({ config: createTestConfig() });
|
||||||
|
apps.push(app);
|
||||||
|
const start = await connect(app);
|
||||||
|
expect(start.statusCode).toBe(201);
|
||||||
|
const url = new URL(start.json().authorizationUrl as string);
|
||||||
|
expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize");
|
||||||
|
expect(url.searchParams.get("client_id")).toBe("joystick-client");
|
||||||
|
expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read");
|
||||||
|
expect(url.searchParams.get("redirect_uri")).toBe("https://heimdall.gamecult.org/v1/oauth/joystick/callback");
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubJoystick(identity: Record<string, unknown>) {
|
||||||
|
const calls: Array<{ url: string; headers: Record<string, string>; body?: URLSearchParams }> = [];
|
||||||
|
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
calls.push({ url, headers: (init?.headers ?? {}) as Record<string, string>, body: init?.body as URLSearchParams | undefined });
|
||||||
|
if (url === "https://api.joystick.tv/api/oauth/token") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ access_token: "js-access", refresh_token: "js-refresh", token_type: "Bearer", expires_in: 864000 }),
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url === "https://api.joystick.tv/api/v1/me/identity") {
|
||||||
|
return new Response(JSON.stringify(identity), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response("unexpected", { status: 500 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("exchanges the code with Basic client auth and links the identity by channel id", async () => {
|
||||||
|
const store = new InMemoryStore();
|
||||||
|
const app = await buildApp({ config: createTestConfig(), store });
|
||||||
|
apps.push(app);
|
||||||
|
const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" });
|
||||||
|
const stateToken = (await connect(app)).json().stateToken as string;
|
||||||
|
|
||||||
|
const callback = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`,
|
||||||
|
});
|
||||||
|
expect(callback.statusCode).toBeLessThan(300);
|
||||||
|
|
||||||
|
const exchange = calls.find((c) => c.url === "https://api.joystick.tv/api/oauth/token")!;
|
||||||
|
expect(exchange.headers.Authorization).toBe(`Basic ${Buffer.from("joystick-client:joystick-secret").toString("base64")}`);
|
||||||
|
expect(exchange.body!.get("grant_type")).toBe("authorization_code");
|
||||||
|
expect(exchange.body!.get("code")).toBe("the-code");
|
||||||
|
expect(exchange.body!.get("redirect_uri")).toBe("https://heimdall.gamecult.org/v1/oauth/joystick/callback");
|
||||||
|
expect(exchange.body!.has("client_secret")).toBe(false);
|
||||||
|
const identityCall = calls.find((c) => c.url === "https://api.joystick.tv/api/v1/me/identity")!;
|
||||||
|
expect(identityCall.headers.Authorization).toBe("Bearer js-access");
|
||||||
|
|
||||||
|
const accountId = callback.json().account.id as string;
|
||||||
|
const row = await store.findStoredLinkedIdentityForAccount(accountId, "joystick");
|
||||||
|
expect(row).toMatchObject({ providerUserId: "chan-9", username: "deru", displayName: "Deru!" });
|
||||||
|
expect(row?.accessTokenEncrypted).toEqual(expect.any(String));
|
||||||
|
expect(row?.accessTokenEncrypted).not.toContain("js-access");
|
||||||
|
expect(row?.refreshTokenEncrypted).toEqual(expect.any(String));
|
||||||
|
const left = Date.parse(row!.tokenExpiresAt!) - Date.now();
|
||||||
|
expect(left).toBeGreaterThan(863_000 * 1000);
|
||||||
|
expect(left).toBeLessThanOrEqual(864_000 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the username for the display name", async () => {
|
||||||
|
const store = new InMemoryStore();
|
||||||
|
const app = await buildApp({ config: createTestConfig(), store });
|
||||||
|
apps.push(app);
|
||||||
|
stubJoystick({ channel_id: "chan-9", username: "deru" });
|
||||||
|
const stateToken = (await connect(app)).json().stateToken as string;
|
||||||
|
const callback = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
|
||||||
|
});
|
||||||
|
const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick");
|
||||||
|
expect(row?.displayName).toBe("deru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails the callback when the identity has no channel id", async () => {
|
||||||
|
const store = new InMemoryStore();
|
||||||
|
const app = await buildApp({ config: createTestConfig(), store });
|
||||||
|
apps.push(app);
|
||||||
|
stubJoystick({ username: "deru" });
|
||||||
|
const stateToken = (await connect(app)).json().stateToken as string;
|
||||||
|
const callback = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
|
||||||
|
});
|
||||||
|
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("no longer honors a global shared secret across apps", async () => {
|
it("no longer honors a global shared secret across apps", async () => {
|
||||||
// A per-app identity model with a global fallback is a two-app shared
|
// A per-app identity model with a global fallback is a two-app shared
|
||||||
// secret in practice (R6, fork 2). GC_ACCESS_APP_SHARED_SECRET alone must
|
// secret in practice (R6, fork 2). GC_ACCESS_APP_SHARED_SECRET alone must
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ function testConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
|
||||||
twitch: {},
|
twitch: {},
|
||||||
youtube: {},
|
youtube: {},
|
||||||
spotify: {},
|
spotify: {},
|
||||||
|
joystick: {},
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ function testConfig(): HeimdallConfig {
|
||||||
twitch: {},
|
twitch: {},
|
||||||
youtube: {},
|
youtube: {},
|
||||||
spotify: {},
|
spotify: {},
|
||||||
|
joystick: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ function testConfig(): HeimdallConfig {
|
||||||
twitch: {},
|
twitch: {},
|
||||||
youtube: {},
|
youtube: {},
|
||||||
spotify: {},
|
spotify: {},
|
||||||
|
joystick: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ const rigKinds = [
|
||||||
{ kind: "postgres two apps", live: true },
|
{ kind: "postgres two apps", live: true },
|
||||||
] as const;
|
] as const;
|
||||||
// Providers whose refresh goes through the real shared runtime; rotation differs by descriptor.
|
// Providers whose refresh goes through the real shared runtime; rotation differs by descriptor.
|
||||||
const refreshProviders: Provider[] = ["twitch"];
|
const refreshProviders: Provider[] = ["twitch", "joystick"];
|
||||||
|
|
||||||
for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`managed credential refresh, ${kind}`, () => {
|
for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`managed credential refresh, ${kind}`, () => {
|
||||||
const admin = live && adminUrl ? new Pool({ connectionString: adminUrl, max: 2 }) : undefined;
|
const admin = live && adminUrl ? new Pool({ connectionString: adminUrl, max: 2 }) : undefined;
|
||||||
|
|
@ -392,16 +392,39 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
||||||
expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1");
|
expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a response without a refresh token keeps the old one for a non-rotating provider", timeout, async () => {
|
if (providerCatalog[provider].rotatesRefreshTokens) {
|
||||||
expect(providerCatalog[provider].rotatesRefreshTokens).toBe(false);
|
it("rotation-fails-closed: a response without a refresh token revokes, 410, and the old token is never presented again", timeout, async () => {
|
||||||
const rig = await makeRig(provider);
|
const rig = await makeRig(provider);
|
||||||
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
||||||
endpoint.mode = "no_refresh_token";
|
endpoint.mode = "no_refresh_token";
|
||||||
const answer = await rig.resolve();
|
|
||||||
expect(answer.statusCode).toBe(200);
|
const first = await rig.resolve();
|
||||||
expect(answer.json.accessToken).toBe("access-norefresh");
|
expect(first.statusCode).toBe(410);
|
||||||
expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0");
|
expect(first.json).toEqual({ error: "managed_credential_revoked", provider });
|
||||||
});
|
expect(first.body).not.toContain("access-norefresh");
|
||||||
|
const row = await rig.row();
|
||||||
|
expect(row.accessTokenEncrypted).toBeUndefined();
|
||||||
|
expect(row.refreshTokenEncrypted).toBeUndefined();
|
||||||
|
expect(row.tokenExpiresAt).toBeUndefined();
|
||||||
|
|
||||||
|
const second = await rig.resolve();
|
||||||
|
expect(second.statusCode).toBe(410);
|
||||||
|
expect(endpoint.calls).toBe(1);
|
||||||
|
expect((await rig.audit()).filter((e) => e.eventType === "managed_credential_revoked")).toEqual([
|
||||||
|
{ eventType: "managed_credential_revoked", payload: { provider, reason: "rotation_missing" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
it("a response without a refresh token keeps the old one for a non-rotating provider", timeout, async () => {
|
||||||
|
const rig = await makeRig(provider);
|
||||||
|
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
||||||
|
endpoint.mode = "no_refresh_token";
|
||||||
|
const answer = await rig.resolve();
|
||||||
|
expect(answer.statusCode).toBe(200);
|
||||||
|
expect(answer.json.accessToken).toBe("access-norefresh");
|
||||||
|
expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it("a row with no access token resolves as revoked (410), a missing row as not found (404)", timeout, async () => {
|
it("a row with no access token resolves as revoked (410), a missing row as not found (404)", timeout, async () => {
|
||||||
const rig = await makeRig(provider, { accessToken: null, refreshToken: null });
|
const rig = await makeRig(provider, { accessToken: null, refreshToken: null });
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ function config(): HeimdallConfig {
|
||||||
bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
||||||
appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {},
|
appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {},
|
||||||
storage: { backend: "memory", applySchemaOnStartup: true },
|
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||||
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {} },
|
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {} },
|
||||||
} as HeimdallConfig;
|
} as HeimdallConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
|
||||||
twitch: {},
|
twitch: {},
|
||||||
youtube: {},
|
youtube: {},
|
||||||
spotify: {},
|
spotify: {},
|
||||||
|
joystick: {},
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ describe("Heimdall Verse witness advertisement", () => {
|
||||||
"twitch",
|
"twitch",
|
||||||
"youtube",
|
"youtube",
|
||||||
"spotify",
|
"spotify",
|
||||||
|
"joystick",
|
||||||
]);
|
]);
|
||||||
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
|
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
|
||||||
"repixelizer",
|
"repixelizer",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue