diff --git a/src/commands/gxgames/client.ts b/src/commands/gxgames/api/index.ts similarity index 75% rename from src/commands/gxgames/client.ts rename to src/commands/gxgames/api/index.ts index 12194d0..8616281 100644 --- a/src/commands/gxgames/client.ts +++ b/src/commands/gxgames/api/index.ts @@ -15,15 +15,22 @@ */ import type { Context } from "~/context"; -import { Gamedev } from "./api/generated/Gamedev"; -import type { AuthManager } from "./auth"; -import { GG_API } from "./config"; +import { Gamedev } from "./generated/Gamedev"; +import { GG_API } from "../config"; -export const getApiClient = (ctx: Context, auth: AuthManager) => - new Gamedev({ +export { LinkStorage, type GxGamesLink } from "./storage"; + +export function getApiClient( + ctx: Context, + auth: { + getAccessToken(): Promise; + }, +) { + return new Gamedev({ customFetch: ctx.fetch, baseUrl: GG_API, securityWorker: async () => ({ headers: { Authorization: `Bearer ${await auth.getAccessToken()}` }, }), }); +} diff --git a/src/commands/gxgames/api/storage.ts b/src/commands/gxgames/api/storage.ts new file mode 100644 index 0000000..e7b7d41 --- /dev/null +++ b/src/commands/gxgames/api/storage.ts @@ -0,0 +1,60 @@ +/** + * Copyright 2026, Opera Norway AS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Context } from "~/context"; +import { KnownError } from "~/error"; +import { Cache } from "~/cache"; +import { z } from "zod"; +import { CACHE_SUBDIR } from "../config"; + +const LINK_FILENAME = "link.json"; + +const LinkSchema = z.object({ + studioId: z.string(), + gameId: z.string(), +}); + +export type GxGamesLink = z.infer; + +export class LinkStorage { + constructor(private readonly ctx: Context) {} + + async read(): Promise { + const cache = await Cache.initLazy(this.ctx, { type: "infer" }); + const dir = await cache.getSubDirPath(this.ctx, CACHE_SUBDIR); + try { + const raw = await this.ctx.fs.readFile( + this.ctx.path.join(dir, LINK_FILENAME), + "utf-8", + ); + return LinkSchema.parse(JSON.parse(raw)); + } catch { + throw new KnownError( + "Game not linked. Run `gm-cli gxgames link --studioid --gameid ` first.", + ); + } + } + + async write(link: GxGamesLink): Promise { + // TODO: later, we may want to store this as part of the "manifest" file instead under a "tools.gxgames" key. + const cache = await Cache.initLazy(this.ctx, { type: "infer" }); + const dir = await cache.getSubDirPath(this.ctx, CACHE_SUBDIR); + await this.ctx.fs.writeFile( + this.ctx.path.join(dir, LINK_FILENAME), + JSON.stringify(link, null, 2), + ); + } +} diff --git a/src/commands/gxgames/auth.ts b/src/commands/gxgames/auth.ts deleted file mode 100644 index 3dece68..0000000 --- a/src/commands/gxgames/auth.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Copyright 2026, Opera Norway AS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at: - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { KnownError } from "~/error"; -import crypto from "node:crypto"; -import type { Context } from "~/context"; -import { readAuth, writeAuth } from "./link"; -import { Cache } from "~/cache"; -import { - AUTH_BASE, - CLIENT_ID, - REDIRECT_PORT, - REDIRECT_URI, - SCOPE, -} from "./config"; - -function waitForAuthCode(ctx: Context): Promise { - return new Promise((resolve, reject) => { - const server = ctx.http.createServer((req, res) => { - const url = new URL(req.url ?? "/", REDIRECT_URI); - const code = url.searchParams.get("code"); - res.writeHead(200, { "Content-Type": "text/html" }); - // TODO: Seems like we can't close this automatically. We could make this page a bit nices though! - // We should also handle errors here - res.end( - [ - "", - "", - "

Login successful. You can close this tab.

", - "", - "", - ].join("\n"), - ); - server.close(); - if (code) { - resolve(code); - } else { - reject(new KnownError("No auth code in redirect")); - } - }); - server.on("error", reject); - server.listen(REDIRECT_PORT); - }); -} - -async function exchangeCodeForToken( - code: string, - state: string, -): Promise<{ accessToken: string; expiresAt: number }> { - const res = await fetch(new URL("/oauth2/v1/token/", AUTH_BASE), { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: - new URLSearchParams({ - grant_type: "authorization_code", - code, - client_id: CLIENT_ID, - redirect_uri: REDIRECT_URI, - state, - }).toString() + `&scope=${SCOPE}`, - }); - if (!res.ok) { - throw new KnownError(`Token exchange failed: ${res.statusText}`); - } - const json = (await res.json()) as { - access_token?: string; - expires_in?: number; - }; - if (!json.access_token) { - throw new KnownError("No access_token in response"); - } - const expiresIn = json.expires_in ?? 3600; - return { - accessToken: json.access_token, - // 60s buffer so we re-auth before the token actually expires - expiresAt: Date.now() + (expiresIn - 60) * 1000, - }; -} - -export interface AuthManager { - getAccessToken(): Promise; -} - -export function createAuthManager(ctx: Context): AuthManager { - return { getAccessToken: () => authenticate(ctx) }; -} - -export async function authenticate(ctx: Context): Promise { - const cache = await Cache.initLazy(ctx, { type: "infer" }); - const cached = await readAuth(ctx, cache); - if (cached && cached.expiresAt > Date.now()) { - return cached.accessToken; - } - - const state = crypto.randomBytes(16).toString("hex"); - - const authUrl = new URL("/oauth2/v1/authorize/", AUTH_BASE); - authUrl.search = - new URLSearchParams({ - response_type: "code", - client_id: CLIENT_ID, - redirect_uri: REDIRECT_URI, - state, - }).toString() + `&scope=${SCOPE}`; - - const codePromise = waitForAuthCode(ctx); - await ctx.open(authUrl.toString()); - - const log = ctx.makeTaskLogger("Authenticating"); - log.message("Waiting for browser login..."); - const code = await codePromise; - const { accessToken, expiresAt } = await exchangeCodeForToken(code, state); - await writeAuth(ctx, { accessToken, expiresAt }, cache); - log.success("Authenticated"); - - return accessToken; -} diff --git a/src/commands/gxgames/auth/error-page.html b/src/commands/gxgames/auth/error-page.html deleted file mode 100644 index e69de29..0000000 diff --git a/src/commands/gxgames/auth/error.html b/src/commands/gxgames/auth/error.html new file mode 100644 index 0000000..f8ef0d5 --- /dev/null +++ b/src/commands/gxgames/auth/error.html @@ -0,0 +1,66 @@ + + + + + + GX.games — Authentication Failed + + + +
+

Authentication failed

+

Something went wrong. You can close this tab and try again.

+
{{ERROR_MESSAGE}}
+
+ + diff --git a/src/commands/gxgames/auth/index.ts b/src/commands/gxgames/auth/index.ts new file mode 100644 index 0000000..05d87ff --- /dev/null +++ b/src/commands/gxgames/auth/index.ts @@ -0,0 +1,194 @@ +/** + * Copyright 2026, Opera Norway AS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { KnownError } from "~/error"; +import crypto from "node:crypto"; +import type { Context } from "~/context"; +import type { TaskLogger } from "~/log"; +import { AuthStorage, type GxGamesAuth } from "./storage"; +import { AUTH_BASE, CLIENT_ID, REDIRECT_PORT, SCOPE } from "../config"; +import successHtml from "./success.html"; +import errorHtml from "./error.html"; +import type http from "node:http"; +import type open from "tiny-open"; + +interface AuthConfig { + fetch: typeof fetch; + http: typeof http; + open: typeof open; + makeTaskLogger: TaskLogger; + storage: AuthStorage; + baseUrl?: string; + redirectPort?: number; +} + +class Auth { + private readonly baseUrl: string; + private readonly redirectPort: number; + private readonly redirectUri: string; + + constructor(private readonly config: AuthConfig) { + this.baseUrl = config.baseUrl ?? AUTH_BASE; + this.redirectPort = config.redirectPort ?? REDIRECT_PORT; + this.redirectUri = `http://localhost:${String(this.redirectPort)}/`; + } + + private async exchangeToken( + params: Record, + ): Promise { + const res = await this.config.fetch( + new URL("/oauth2/v1/token/", this.baseUrl), + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: + new URLSearchParams({ ...params, client_id: CLIENT_ID }).toString() + + `&scope=${SCOPE}`, + }, + ); + + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { + error?: string; + } | null; + throw new KnownError( + `Token exchange failed: ${body?.error ?? res.statusText}`, + ); + } + + const json = (await res.json()) as { + access_token?: string; + expires_in?: number; + refresh_token?: string; + }; + + if (!json.access_token) { + throw new KnownError("No access_token in response"); + } + + return { + accessToken: json.access_token, + // 60s buffer so we re-auth before the token actually expires + expiresAt: Date.now() + ((json.expires_in ?? 3600) - 60) * 1000, + refreshToken: json.refresh_token, + }; + } + + private async browserAuth(): Promise { + const state = crypto.randomBytes(16).toString("hex"); + + const authUrl = new URL("/oauth2/v1/authorize/", this.baseUrl); + authUrl.search = + new URLSearchParams({ + response_type: "code", + client_id: CLIENT_ID, + redirect_uri: this.redirectUri, + state, + }).toString() + `&scope=${SCOPE}`; + + const codePromise = new Promise<{ + code: string; + sendPage: (html: string) => void; + }>((resolve, reject) => { + const server = this.config.http.createServer((req, res) => { + const url = new URL(req.url ?? "/", this.redirectUri); + const code = url.searchParams.get("code"); + const returnedState = url.searchParams.get("state"); + const errorDescription = + url.searchParams.get("error_description") ?? + url.searchParams.get("error"); + + res.writeHead(200, { "Content-Type": "text/html" }); + server.close(); + + if (returnedState !== state) { + const msg = "State mismatch"; + res.end(errorHtml.replace("{{ERROR_MESSAGE}}", msg)); + reject(new KnownError(msg)); + } else if (code) { + resolve({ code, sendPage: (html) => res.end(html) }); + } else { + const msg = errorDescription ?? "No auth code in redirect"; + res.end(errorHtml.replace("{{ERROR_MESSAGE}}", msg)); + reject(new KnownError(msg)); + } + }); + server.on("error", reject); + server.listen(this.redirectPort); + }); + + await this.config.open(authUrl.toString()); + + const log = this.config.makeTaskLogger("Authenticating"); + log.message("Waiting for browser login..."); + + const { code, sendPage } = await codePromise; + + try { + const auth = await this.exchangeToken({ + grant_type: "authorization_code", + code, + redirect_uri: this.redirectUri, + }); + sendPage(successHtml); + log.success("Authenticated"); + return auth; + } catch (err) { + sendPage( + errorHtml.replace( + "{{ERROR_MESSAGE}}", + err instanceof Error ? err.message : String(err), + ), + ); + throw err; + } + } + + async getAccessToken(): Promise { + const cached = await this.config.storage.read(); + + if (cached && cached.expiresAt > Date.now()) { + return cached.accessToken; + } + + if (cached?.refreshToken) { + try { + const auth = await this.exchangeToken({ + grant_type: "refresh_token", + refresh_token: cached.refreshToken, + }); + await this.config.storage.write(auth); + return auth.accessToken; + } catch { + // fall through to browser auth + } + } + + const auth = await this.browserAuth(); + await this.config.storage.write(auth); + return auth.accessToken; + } +} + +export function createAuthManager(ctx: Context) { + return new Auth({ + fetch: ctx.fetch, + http: ctx.http, + open: ctx.open, + makeTaskLogger: ctx.makeTaskLogger, + storage: new AuthStorage(ctx), + }); +} diff --git a/src/commands/gxgames/auth/storage.ts b/src/commands/gxgames/auth/storage.ts new file mode 100644 index 0000000..d6575cf --- /dev/null +++ b/src/commands/gxgames/auth/storage.ts @@ -0,0 +1,61 @@ +/** + * Copyright 2026, Opera Norway AS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Context } from "~/context"; +import { Cache } from "~/cache"; +import { z } from "zod"; +import { CACHE_SUBDIR } from "../config"; + +const AUTH_FILENAME = "auth.json"; + +const AuthSchema = z.object({ + accessToken: z.string(), + expiresAt: z.number(), + refreshToken: z.string().optional(), +}); + +export type GxGamesAuth = z.infer; + +export class AuthStorage { + constructor(private readonly ctx: Context) {} + + async read(): Promise { + const cache = await Cache.initLazy(this.ctx, { type: "infer" }); + const dir = await cache.getSubDirPath(this.ctx, CACHE_SUBDIR, { + preferShared: true, + }); + try { + const raw = await this.ctx.fs.readFile( + this.ctx.path.join(dir, AUTH_FILENAME), + "utf-8", + ); + return AuthSchema.parse(JSON.parse(raw)); + } catch { + return undefined; + } + } + + async write(auth: GxGamesAuth): Promise { + const cache = await Cache.initLazy(this.ctx, { type: "infer" }); + const dir = await cache.getSubDirPath(this.ctx, CACHE_SUBDIR, { + preferShared: true, + }); + await this.ctx.fs.writeFile( + this.ctx.path.join(dir, AUTH_FILENAME), + JSON.stringify(auth, null, 2), + ); + } +} diff --git a/src/commands/gxgames/auth/success-page.html b/src/commands/gxgames/auth/success-page.html deleted file mode 100644 index e69de29..0000000 diff --git a/src/commands/gxgames/auth/success.html b/src/commands/gxgames/auth/success.html new file mode 100644 index 0000000..f852961 --- /dev/null +++ b/src/commands/gxgames/auth/success.html @@ -0,0 +1,56 @@ + + + + + + GX.games — Authenticated + + + +
+

Authentication successful

+

+ You're signed in to GX.games. You can close this tab and return to the + terminal. +

+
+ + diff --git a/src/commands/gxgames/commands/link-impl.ts b/src/commands/gxgames/commands/link-impl.ts index 9c74467..47fe2e9 100644 --- a/src/commands/gxgames/commands/link-impl.ts +++ b/src/commands/gxgames/commands/link-impl.ts @@ -17,10 +17,10 @@ import * as p from "@clack/prompts"; import type { Context } from "~/context"; import { KnownError } from "~/error"; -import { Cache } from "~/cache"; + import { createAuthManager } from "../auth"; -import { getApiClient } from "../client"; -import { writeLink } from "../link"; +import { getApiClient } from "../api"; +import { LinkStorage } from "../api"; export default async function ( this: Context, @@ -97,7 +97,6 @@ export default async function ( } } - const cache = await Cache.initLazy(this, { type: "infer" }); - await writeLink(this, { studioId, gameId }, cache); + await new LinkStorage(this).write({ studioId, gameId }); p.log.success(`Linked to studio ${studioId}, game ${gameId}`); } diff --git a/src/commands/gxgames/commands/meta-impl.ts b/src/commands/gxgames/commands/meta-impl.ts index 9e8e0e2..2fb7d46 100644 --- a/src/commands/gxgames/commands/meta-impl.ts +++ b/src/commands/gxgames/commands/meta-impl.ts @@ -17,10 +17,10 @@ import type { Context } from "~/context"; import * as p from "@clack/prompts"; import { KnownError } from "~/error"; -import { Cache } from "~/cache"; -import { readLink } from "../link"; + +import { LinkStorage } from "../api"; import { createAuthManager } from "../auth"; -import { getApiClient } from "../client"; +import { getApiClient } from "../api"; import type { GameDevUpdateGameRequestAgeRatingEnum, GameDevUpdateGameRequestPlatformsEnum, @@ -57,8 +57,7 @@ interface MetaFlags { } export default async function (this: Context, flags: MetaFlags): Promise { - const cache = await Cache.initLazy(this, { type: "infer" }); - const link = await readLink(this, cache); + const link = await new LinkStorage(this).read(); const api = getApiClient(this, createAuthManager(this)); const gameRes = await api.getGameDetails(link.gameId); diff --git a/src/commands/gxgames/commands/publish-impl.ts b/src/commands/gxgames/commands/publish-impl.ts index b05e254..152ac36 100644 --- a/src/commands/gxgames/commands/publish-impl.ts +++ b/src/commands/gxgames/commands/publish-impl.ts @@ -17,17 +17,16 @@ import type { Context } from "~/context"; import * as p from "@clack/prompts"; import { KnownError } from "~/error"; -import { Cache } from "~/cache"; -import { readLink } from "../link"; + +import { LinkStorage } from "../api"; import { createAuthManager } from "../auth"; -import { getApiClient } from "../client"; +import { getApiClient } from "../api"; export default async function ( this: Context, _flags: Record, ): Promise { - const cache = await Cache.initLazy(this, { type: "infer" }); - const link = await readLink(this, cache); + const link = await new LinkStorage(this).read(); const api = getApiClient(this, createAuthManager(this)); const publishLog = this.makeTaskLogger("Publishing game"); diff --git a/src/commands/gxgames/commands/upload-impl.ts b/src/commands/gxgames/commands/upload-impl.ts index 693eefd..37dc0e8 100644 --- a/src/commands/gxgames/commands/upload-impl.ts +++ b/src/commands/gxgames/commands/upload-impl.ts @@ -16,19 +16,18 @@ import type { Context } from "~/context"; import * as p from "@clack/prompts"; -import { getApiClient } from "../client"; +import { getApiClient } from "../api"; import { createAuthManager } from "../auth"; import { KnownError } from "~/error"; -import { Cache } from "~/cache"; -import { readLink } from "../link"; + +import { LinkStorage } from "../api"; export default async function ( this: Context, flags: { version?: string }, file: string, ): Promise { - const cache = await Cache.initLazy(this, { type: "infer" }); - const link = await readLink(this, cache); + const link = await new LinkStorage(this).read(); const api = getApiClient(this, createAuthManager(this)); diff --git a/src/commands/gxgames/config.ts b/src/commands/gxgames/config.ts index 51214a8..64212a2 100644 --- a/src/commands/gxgames/config.ts +++ b/src/commands/gxgames/config.ts @@ -23,5 +23,6 @@ export const SCOPE = [ "https://api.gx.games/gamedev:write", "https://api.gx.games/gamedev:read", ].join("+"); +export const CACHE_SUBDIR = "gxgames"; export const GG_API = "https://api.gx.games"; diff --git a/src/commands/gxgames/link.ts b/src/commands/gxgames/link.ts deleted file mode 100644 index 1642235..0000000 --- a/src/commands/gxgames/link.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Copyright 2026, Opera Norway AS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at: - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Context } from "~/context"; -import { KnownError } from "~/error"; -import { Cache } from "~/cache"; -import { z } from "zod"; - -const STORE_FILENAME = "gxgames.json"; -const CACHE_SUBDIR = "gxgames"; - -const StoreSchema = z.object({ - auth: z.object({ accessToken: z.string(), expiresAt: z.number() }).optional(), - link: z.object({ studioId: z.string(), gameId: z.string() }).optional(), -}); - -type GxGamesStore = z.infer; -export type GxGamesLink = NonNullable; -export type GxGamesAuth = NonNullable; - -async function readStore(ctx: Context, cache: Cache): Promise { - const dir = await cache.getSubDirPath(ctx, CACHE_SUBDIR); - const storePath = ctx.path.join(dir, STORE_FILENAME); - try { - const raw = await ctx.fs.readFile(storePath, "utf-8"); - return StoreSchema.parse(JSON.parse(raw)); - } catch { - return {}; - } -} - -async function writeStore( - ctx: Context, - store: GxGamesStore, - cache: Cache, -): Promise { - // TODO: later, we may want to store this as part of the "manifest" file instead under a "tools.gxgames" key. - const dir = await cache.getSubDirPath(ctx, CACHE_SUBDIR); - const storePath = ctx.path.join(dir, STORE_FILENAME); - await ctx.fs.writeFile(storePath, JSON.stringify(store, null, 2)); -} - -export async function readLink( - ctx: Context, - cache: Cache, -): Promise { - const store = await readStore(ctx, cache); - if (!store.link) { - throw new KnownError( - "Game not linked. Run `gm-cli gxgames link --studioid --gameid ` first.", - ); - } - return store.link; -} - -export async function writeLink( - ctx: Context, - link: GxGamesLink, - cache: Cache, -): Promise { - const store = await readStore(ctx, cache); - await writeStore(ctx, { ...store, link }, cache); -} - -export async function readAuth( - ctx: Context, - cache: Cache, -): Promise { - const store = await readStore(ctx, cache); - return store.auth; -} - -export async function writeAuth( - ctx: Context, - auth: GxGamesAuth, - cache: Cache, -): Promise { - const store = await readStore(ctx, cache); - await writeStore(ctx, { ...store, auth }, cache); -} diff --git a/src/html.d.ts b/src/html.d.ts new file mode 100644 index 0000000..860aae8 --- /dev/null +++ b/src/html.d.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2026, Opera Norway AS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +declare module "*.html" { + const content: string; + export default content; +} diff --git a/tsup.config.ts b/tsup.config.ts index 2612e3f..0a1f4b6 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -14,5 +14,9 @@ export default defineConfig({ options.alias = { "~": "./src", }; + options.loader = { + ...options.loader, + ".html": "text", + }; }, });