Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
run: |
# Exclude redoc HTML from diff check (output varies by platform due to styled-components)
git checkout -- docs/gen/redoc.v1.html 2>/dev/null || true
git checkout -- docs/gen/redoc.ingress.v1.html 2>/dev/null || true

if [ -n "$(git status --porcelain)" ]; then
echo "::error::Generated files are out of date. Please run 'make check' locally and commit the changes."
Expand Down
31 changes: 31 additions & 0 deletions docs/config/swagger-merger-ingress-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"swagger": "2.0",
"info": {
"title": "Stash Managed Catalog API",
"description": "Read-only API for querying the Stash managed product catalog. Designed for server-to-server communication between partner backends and Stash services.\n\n## Base URLs\n\n| Environment | Base URL |\n|---|---|\n| **Test** | `https://test-api.stash.gg` |\n| **Production** | `https://api.stash.gg` |\n\nAll endpoint paths below are relative to the base URL. For example, to list products in the test environment:\n\n```\nGET https://test-api.stash.gg/api/v1/studio/{shop_id}/products\n```\n\n## Authentication\n\nAll requests must include an HMAC-SHA256 signature in the `stash-hmac-signature` header.\n\n### Setup\n\n1. Go to **Stash Studio > Project Settings > API Secrets**\n2. Click **Generate Secret** to create a new key\n3. Keys created through this portal are Ingress keys by default and work with this API (this is the same key used for `/sdk/` API authentication)\n4. Store the secret securely — you will need it to sign every request\n\n### Signing a Request\n\n1. For GET requests (no body), sign an **empty string** using HMAC-SHA256 with your API secret\n2. Base64-encode the resulting signature\n3. Send it in the `stash-hmac-signature` HTTP header\n\n### Example (curl)\n\n```bash\nSIGNATURE=$(echo -n \"\" | openssl dgst -sha256 -hmac \"YOUR_API_SECRET\" -binary | base64)\ncurl -H \"stash-hmac-signature: $SIGNATURE\" \\\n \"https://test-api.stash.gg/api/v1/studio/{shop_id}/products\"\n```",
"version": "1.0.0",
"contact": {
"name": "API Support",
"url": "https://docs.stash.gg/",
"email": "help@stash.gg"
}
},
"schemes": ["https"],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": [
{
"name": "ManagedCatalog",
"description": "Read-only endpoints for querying the managed product catalog"
}
],
"paths": {
"$ref": "../../gen/openapiv2/server/ingress/studio/v1/service.swagger.json#paths"
},
"definitions": {
"$ref": "../../gen/openapiv2/server/ingress/studio/v1/service.swagger.json#definitions"
},
"securityDefinitions": {
"$ref": "../../gen/openapiv2/server/ingress/server.swagger.json#securityDefinitions"
}
}
515 changes: 515 additions & 0 deletions docs/gen/redoc.ingress.v1.html

Large diffs are not rendered by default.

1,391 changes: 1,391 additions & 0 deletions docs/gen/swagger.ingress.v1.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions gen.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,10 @@ npx --yes swagger-merger@1.5.4 -i ./docs/config/swagger-merger-config.json -o ./
echo "Building Redoc static HTML file..."
npx --yes @redocly/cli@2.6.0 build-docs ./docs/gen/swagger.v1.json -o ./docs/gen/redoc.v1.html

echo "Merging Ingress Swagger files..."
npx swagger-merger@1.5.4 -i ./docs/config/swagger-merger-ingress-config.json -o ./docs/gen/swagger.ingress.v1.json

echo "Building Ingress Redoc static HTML file..."
npx @redocly/cli@2.6.0 build-docs ./docs/gen/swagger.ingress.v1.json -o ./docs/gen/redoc.ingress.v1.html

echo "Code generation completed successfully!"
34 changes: 34 additions & 0 deletions server/ingress/server.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
syntax = "proto3";

// buf:lint:ignore PACKAGE_VERSION_SUFFIX
package server.ingress;

import "protoc-gen-openapiv2/options/annotations.proto";

option go_package = "github.com/stashgg/public-api/gen/proto/server/ingress";
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
info: {
title: "Stash Managed Catalog API"
description: "Read-only API for querying the Stash managed product catalog. Designed for server-to-server communication between partner backends and Stash services."
version: "1.0"
contact: {
name: "API Support"
url: "https://docs.stash.gg/"
email: "help@stash.gg"
}
}
schemes: HTTPS
consumes: "application/json"
produces: "application/json"
security_definitions: {
security: {
key: "hmac"
value: {
name: "stash-hmac-signature"
in: IN_HEADER
type: TYPE_API_KEY
description: "HMAC-SHA256 signature generated by signing the request body (empty string for GET requests) with the Ingress API key from Stash Studio. Base64-encoded."
}
}
}
};
21 changes: 21 additions & 0 deletions server/ingress/studio/v1/enums.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
syntax = "proto3";

package server.ingress.studio.v1;

import "protoc-gen-openapiv2/options/annotations.proto";

option go_package = "github.com/stashgg/public-api/gen/proto/server/ingress/studio/v1";

enum ProductStatus {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_enum) = {description: "Product publication status. This API only returns published products."};
reserved 1;
reserved "PRODUCT_STATUS_DRAFT";
PRODUCT_STATUS_UNSPECIFIED = 0;
PRODUCT_STATUS_PUBLISHED = 2;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: looks like ProductStatus jumps from UNSPECIFIED=0 to PUBLISHED=2, skipping 1

is that intentional?

if your intent was to reserve 1 for DRAFT in case it's needed at some point, consider doing:

enum ProductStatus {
  reserved 1;
  reserved "PRODUCT_STATUS_DRAFT"; // DRAFT not supported in this API
  PRODUCT_STATUS_UNSPECIFIED = 0;
  PRODUCT_STATUS_PUBLISHED = 2;
}

}

enum ProductPricePlatform {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_enum) = {description: "Platform for a price entry"};
PRODUCT_PRICE_PLATFORM_UNSPECIFIED = 0;
PRODUCT_PRICE_PLATFORM_UNIVERSAL = 1;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: looks like ANDROID and IOS were omitted.

that's fine for now, you can always add them later.

just checking that was intentional :)

}
220 changes: 220 additions & 0 deletions server/ingress/studio/v1/service.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
syntax = "proto3";

package server.ingress.studio.v1;

import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
import "google/protobuf/timestamp.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "server/ingress/studio/v1/enums.proto";
import "server/ingress/studio/v1/types.proto";
import "validate/validate.proto";

option go_package = "github.com/stashgg/public-api/gen/proto/server/ingress/studio/v1";

service ManagedCatalogService {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_tag) = {
name: "ManagedCatalog"
description: "Read-only endpoints for querying the managed product catalog"
};
rpc ListProducts(ListProductsRequest) returns (ListProductsResponse) {
option (google.api.http) = {get: "/api/v1/studio/{shop_id}/products"};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "List published products"
description: "Retrieves a paginated list of published products for a shop. Results are ordered by creation date (most recent first). Only published products are returned; drafts and deleted products are excluded."
security: [
{
security_requirement: {key: "hmac"}
}
]
};
}
rpc GetProduct(GetProductRequest) returns (GetProductResponse) {
option (google.api.http) = {get: "/api/v1/studio/{shop_id}/products/{guid}"};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Get a single published product"
description: "Retrieves a single published product by its GUID. Returns NOT_FOUND if the product does not exist or is not published."
security: [
{
security_requirement: {key: "hmac"}
}
]
};
}
}

message Product {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
json_schema: {
title: "Product"
description: "A published product from the managed catalog"
}
};
string guid = 1 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Stash-internal unique product identifier (UUID)"}];
LocalizableText name = 2 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Product name"}];
optional string product_id = 3 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Partner-defined product identifier (e.g., SKU). Always non-empty for published products"}];
optional LocalizableText description = 4 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Product description. Always non-empty for published products"}];
optional Images images = 5 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Product images"}];
ProductStatus status = 6 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Product status. Always PRODUCT_STATUS_PUBLISHED for this API"
default: "PRODUCT_STATUS_PUBLISHED"
example: "\"PRODUCT_STATUS_PUBLISHED\""
}];
google.protobuf.Timestamp created_at = 7 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Timestamp when the product was created (RFC 3339)"}];
google.protobuf.Timestamp updated_at = 8 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Timestamp when the product was last updated (RFC 3339)"}];
optional LocalizableText display_name = 9 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Human-readable display name. Always non-empty for published products"}];
repeated Price prices = 10 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Array of price entries. At least one entry (USD/UNIVERSAL/US) is guaranteed for paid products. Free gift products have an empty array."
example: "[{\"currency\": \"USD\", \"platform\": \"PRODUCT_PRICE_PLATFORM_UNIVERSAL\", \"region\": \"US\", \"cents\": 499, \"updatedAt\": \"2026-01-15T10:30:00Z\"}]"
}];
repeated Item items = 11 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Items contained in this product. May be empty if no items configured. If present, each item has a non-empty image and quantity > 0."
example: "[{\"image\": \"https://example.com/coin.png\", \"quantity\": \"100\", \"displayName\": {\"defaultText\": \"Gold Coin\"}}]"
}];
}

message Images {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
json_schema: {
title: "Images"
description: "Product images"
}
};
string main_image = 1 [
(validate.rules).string = {
uri: true
ignore_empty: true
},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Primary product image URL. Always non-empty for published products"}
];
optional string background_image = 2 [
(validate.rules).string = {
uri: true
ignore_empty: true
},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Background image URL"}
];
}

message Price {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
json_schema: {
title: "Price"
description: "A price entry for a product"
}
};
string currency = 1 [
(validate.rules).string = {
min_len: 3
max_len: 3
},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Currency code (ISO-4217, e.g., \"USD\", \"EUR\")"}
];
ProductPricePlatform platform = 2 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Platform (e.g., UNIVERSAL)"
default: "PRODUCT_PRICE_PLATFORM_UNIVERSAL"
example: "\"PRODUCT_PRICE_PLATFORM_UNIVERSAL\""
}];
string region = 3 [
(validate.rules).string = {
min_len: 2
max_len: 3
},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Region code (ISO-3166-1 alpha-2, e.g., \"US\", \"EU\")"}
];
uint32 cents = 4 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Price in smallest currency unit (e.g., 499 for $4.99)"}];
google.protobuf.Timestamp updated_at = 5 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Timestamp when this price was last updated (RFC 3339)"}];
}

message Item {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
json_schema: {
title: "Item"
description: "An item contained in a product"
}
};
string image = 1 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "URL to the item image. Always non-empty for published products"}];
string quantity = 2 [
(validate.rules).string = {
min_len: 1
pattern: "^[0-9]+$"
},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "In-game item quantity as string for big integer support (no decimals allowed). Always > 0 for published products"}
];
optional LocalizableText display_name = 3 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Optional display name for the item (max 50 characters)"}];
}

message ListProductsRequest {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
json_schema: {
title: "List Products Request"
description: "Request to list published products for a shop"
required: ["shop_id"]
}
};
string shop_id = 1 [
(google.api.field_behavior) = REQUIRED,
(validate.rules).string.uuid = true,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Shop identifier from Stash Studio"}
];
optional uint32 limit = 2 [
(validate.rules).uint32.lte = 100,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Maximum results per page. Defaults to 50. Maximum allowed value is 100."
minimum: 1
maximum: 100
}
];
optional uint32 offset = 3 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Pagination offset. Use next_offset from the previous response to fetch the next page. Defaults to 0."
minimum: 0
}];
}

message ListProductsResponse {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
json_schema: {
title: "List Products Response"
description: "Paginated list of published products"
}
};
repeated Product products = 1 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "List of published products"}];
optional uint32 next_offset = 2 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Offset for the next page. Omitted if there are no more results."}];
}

message GetProductRequest {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
json_schema: {
title: "Get Product Request"
description: "Request to get a single published product"
required: [
"shop_id",
"guid"
]
}
};
string shop_id = 1 [
(google.api.field_behavior) = REQUIRED,
(validate.rules).string.uuid = true,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Shop identifier from Stash Studio"}
];
string guid = 2 [
(google.api.field_behavior) = REQUIRED,
(validate.rules).string.uuid = true,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Stash-internal unique product identifier (UUID)"}
];
}

message GetProductResponse {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
json_schema: {
title: "Get Product Response"
description: "Response containing a single published product"
required: ["product"]
}
};
Product product = 1 [
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "The requested product"}
];
}
34 changes: 34 additions & 0 deletions server/ingress/studio/v1/types.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
syntax = "proto3";

package server.ingress.studio.v1;

import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";

option go_package = "github.com/stashgg/public-api/gen/proto/server/ingress/studio/v1";

message LocalizableText {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
json_schema: {
title: "Localizable Text"
description: "Text content that can be localized for a specific language"
}
};
string default_text = 1 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Default text content, usually in English"}];
oneof localization {
string localization_key = 2 [
(validate.rules).string = {
min_len: 1
ignore_empty: true
},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Key for client-side localization lookup"}
];
string localized_text = 3 [
(validate.rules).string = {
min_len: 1
ignore_empty: true
},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Server-resolved localized text"}
];
}
}
Loading