diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index aa1b9bea69..7fc5b11e39 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -1,4 +1,9 @@ export default { "*.{j,t}s": [() => "npm run build:src:tsgo", "eslint --concurrency 4" /* sweet spot it seems */, "prettier --write"], - "src/schemas/{*,**/*}.ts": [() => "npm run build:src:tsgo", () => "node scripts/schema.js", () => "node scripts/openapi.js", () => "git add assets/schemas.json assets/openapi.json"], + "src/schemas/{*,**/*}.ts": [ + () => "npm run build:src:tsgo", + () => "node scripts/schema.js", + () => "node scripts/openapi.js", + () => "git add assets/schemas.json assets/openapi.json", + ], }; diff --git a/assets/openapi.json b/assets/openapi.json index aa965def46..84ec66c021 100644 --- a/assets/openapi.json +++ b/assets/openapi.json @@ -901,11 +901,15 @@ "friend_sync": { "type": "boolean" }, - "openid_params": {} + "two_way_link_code": { + "type": "string" + }, + "openid_params": { + "$ref": "#/components/schemas/ConnectionCallbackOpenIdParams" + } }, "required": [ - "friend_sync", - "insecure", + "code", "state" ] }, @@ -7834,6 +7838,12 @@ "fetched_at" ] }, + "ConnectionCallbackOpenIdParams": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "AllowedMentions": { "type": "object", "properties": { diff --git a/assets/schemas.json b/assets/schemas.json index e28447eb84..8b53723e64 100644 --- a/assets/schemas.json +++ b/assets/schemas.json @@ -929,12 +929,16 @@ "friend_sync": { "type": "boolean" }, - "openid_params": {} + "two_way_link_code": { + "type": "string" + }, + "openid_params": { + "$ref": "#/definitions/ConnectionCallbackOpenIdParams" + } }, "additionalProperties": false, "required": [ - "friend_sync", - "insecure", + "code", "state" ], "$schema": "http://json-schema.org/draft-07/schema#" @@ -8338,6 +8342,13 @@ ], "$schema": "http://json-schema.org/draft-07/schema#" }, + "ConnectionCallbackOpenIdParams": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "$schema": "http://json-schema.org/draft-07/schema#" + }, "AllowedMentions": { "type": "object", "properties": { diff --git a/eslint.config.mjs b/eslint.config.mjs index 83ff1e783e..cd8f785e43 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -66,7 +66,7 @@ export default defineConfig([ // "sort-imports": ["error", {}], "default-case": "error", "default-case-last": "error", - "yoda": "error", + yoda: "error", // unsure what the defaults are here, but we want them to error "for-direction": "error", "constructor-super": "error", diff --git a/scripts/openapi.js b/scripts/openapi.js index 3f428fcef5..aa1e2cffbe 100644 --- a/scripts/openapi.js +++ b/scripts/openapi.js @@ -97,7 +97,7 @@ function combineSchemas(schemas) { specification.components = specification.components || {}; specification.components.schemas = specification.components.schemas || {}; specification.components.schemas[key] = definitions[key]; - delete definitions[key].additionalProperties; + if (definitions[key].additionalProperties === false) delete definitions[key].additionalProperties; delete definitions[key].$schema; const definition = definitions[key]; diff --git a/src/cdn/util/Storage.ts b/src/cdn/util/Storage.ts index 7579e2d610..3245f65572 100644 --- a/src/cdn/util/Storage.ts +++ b/src/cdn/util/Storage.ts @@ -86,7 +86,9 @@ if (process.env.STORAGE_PROVIDER === "file" || !process.env.STORAGE_PROVIDER) { const forcePathStyle = process.env.STORAGE_FORCE_PATH_STYLE === "true"; if (process.env.STORAGE_FORCE_PATH_STYLE === undefined) { - console.warn(`[CDN] STORAGE_FORCE_PATH_STYLE is not set for S3 provider; defaulting to virtual-hosted style. Set STORAGE_FORCE_PATH_STYLE=true to enable path-style addressing.`); + console.warn( + `[CDN] STORAGE_FORCE_PATH_STYLE is not set for S3 provider; defaulting to virtual-hosted style. Set STORAGE_FORCE_PATH_STYLE=true to enable path-style addressing.`, + ); } const { S3Storage } = require("./S3Storage"); diff --git a/src/schemas/uncategorised/ConnectionCallbackSchema.test.ts b/src/schemas/uncategorised/ConnectionCallbackSchema.test.ts new file mode 100644 index 0000000000..279d2a2040 --- /dev/null +++ b/src/schemas/uncategorised/ConnectionCallbackSchema.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, test } from "node:test"; +import Ajv from "ajv"; + +const schemaPath = path.join(process.cwd(), "assets", "schemas.json"); +const openApiPath = path.join(process.cwd(), "assets", "openapi.json"); +const rawSchemas = JSON.parse(readFileSync(schemaPath, "utf8")); +const ajvSchemas = JSON.parse(readFileSync(schemaPath, "utf8").replaceAll("#/definitions/", "")); +const openApi = JSON.parse(readFileSync(openApiPath, "utf8")); + +describe("ConnectionCallbackSchema", () => { + test("emits typed OpenID params and Discord-compatible callback fields", () => { + const schema = rawSchemas.ConnectionCallbackSchema; + + assert.equal(schema.properties.code.type, "string"); + assert.equal(schema.properties.state.type, "string"); + assert.equal(schema.properties.insecure.type, "boolean"); + assert.equal(schema.properties.friend_sync.type, "boolean"); + assert.equal(schema.properties.two_way_link_code.type, "string"); + assert.deepEqual(schema.properties.openid_params, { + $ref: "#/definitions/ConnectionCallbackOpenIdParams", + }); + assert.deepEqual(schema.required, ["code", "state"]); + assert.deepEqual(rawSchemas.ConnectionCallbackOpenIdParams, { + type: "object", + additionalProperties: { + type: "string", + }, + $schema: "http://json-schema.org/draft-07/schema#", + }); + }); + + test("preserves typed OpenID params in generated OpenAPI", () => { + assert.deepEqual(openApi.components.schemas.ConnectionCallbackOpenIdParams, { + type: "object", + additionalProperties: { + type: "string", + }, + }); + assert.deepEqual(openApi.components.schemas.ConnectionCallbackSchema.properties.openid_params, { + $ref: "#/components/schemas/ConnectionCallbackOpenIdParams", + }); + }); + + test("validates callback payloads", () => { + const ajv = new Ajv({ + allErrors: true, + schemas: ajvSchemas, + strict: true, + strictRequired: true, + allowUnionTypes: true, + }); + + const validate = ajv.getSchema("ConnectionCallbackSchema"); + assert.ok(validate); + + assert.equal( + validate({ + code: "oauth-code", + state: "state", + openid_params: { + id_token: "id-token", + access_token: "access-token", + scope: "openid profile", + }, + }), + true, + ); + + assert.equal( + validate({ + code: "oauth-code", + state: "state", + insecure: false, + friend_sync: true, + two_way_link_code: "device-code", + openid_params: { + id_token: "id-token", + access_token: "access-token", + scope: "openid profile", + }, + }), + true, + ); + + assert.equal( + validate({ + state: "state", + }), + false, + ); + + assert.equal( + validate({ + code: "oauth-code", + }), + false, + ); + + assert.equal( + validate({ + code: "oauth-code", + state: "state", + unexpected: "field", + }), + false, + ); + + assert.equal( + validate({ + code: "oauth-code", + state: "state", + openid_params: "id-token", + }), + false, + ); + + assert.equal( + validate({ + code: "oauth-code", + state: "state", + openid_params: { + id_token: { + nested: true, + }, + }, + }), + false, + ); + }); +}); diff --git a/src/schemas/uncategorised/ConnectionCallbackSchema.ts b/src/schemas/uncategorised/ConnectionCallbackSchema.ts index 8e5aeee424..0841a3e902 100644 --- a/src/schemas/uncategorised/ConnectionCallbackSchema.ts +++ b/src/schemas/uncategorised/ConnectionCallbackSchema.ts @@ -17,9 +17,14 @@ */ export interface ConnectionCallbackSchema { - code?: string; + code: string; state: string; - insecure: boolean; - friend_sync: boolean; - openid_params?: unknown; // TODO: types + insecure?: boolean; + friend_sync?: boolean; + two_way_link_code?: string; + openid_params?: ConnectionCallbackOpenIdParams; +} + +export interface ConnectionCallbackOpenIdParams { + [key: string]: string; }