Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f0e9077
fix: normalize Clash layout links
MagicTheDev Sep 19, 2026
aa5c1a3
feat: add personal army library API
MagicTheDev Sep 20, 2026
26b6296
chore: support isolated local roster testing
MagicTheDev Sep 20, 2026
0b5c0f3
chore: secure local tunnel access
MagicTheDev Sep 20, 2026
7aaa681
chore: require beta Discord credentials locally
MagicTheDev Sep 20, 2026
cc65efe
chore: remove local Discord user allowlist
MagicTheDev Sep 20, 2026
9a76f2f
feat(api): add roster, CWL, and army analytics contracts
MagicTheDev Sep 25, 2026
74decf4
ci(api): pin expanded DevKit schema for integration
MagicTheDev Sep 25, 2026
7f33e60
chore(api): advance package release candidate
MagicTheDev Sep 25, 2026
4d50504
ci(api): pin reviewed DevKit constraint fixes
MagicTheDev Sep 25, 2026
d7dfdfb
fix(api): make roster publications retry-safe
MagicTheDev Sep 25, 2026
86a59d7
fix(api): align roster admission and saved-base export
MagicTheDev Sep 25, 2026
78e2ee6
test(api): align retained PostgreSQL fixtures
MagicTheDev Sep 25, 2026
0d5d547
ci(api): pin reviewed Goose 040 schema
MagicTheDev Sep 25, 2026
552b2c6
ci(api): pin reviewed Goose 041 schema
MagicTheDev Sep 25, 2026
2c26f3b
fix(api): include saved armies in privacy export
MagicTheDev Sep 25, 2026
42ae3e9
ci(api): pin reviewed Goose 042 schema
MagicTheDev Sep 25, 2026
83ef2b7
fix(api): select dominant army from finalized days
MagicTheDev Sep 25, 2026
acb20b0
ci(api): pin reviewed Goose 043 schema
MagicTheDev Sep 25, 2026
5bd28b6
fix(api): complete public roster and member avatars
MagicTheDev Sep 25, 2026
db0e9cc
fix(api): sync approved roster moves
MagicTheDev Sep 25, 2026
d4314b0
ci(api): pin reviewed Goose 044 schema
MagicTheDev Sep 25, 2026
e27a9e4
fix(api): load roster metadata only when needed
MagicTheDev Sep 25, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,13 @@ jobs:
steps:
- uses: actions/checkout@v6

# Migrations 020/021 supply player snapshots and inline base storage.
# Migrations through 044 supply roster, CWL, and army analytics storage.
# Pin the reviewed contract so CI cannot silently test an older schema.
- name: Checkout authoritative schema and fixture harness
uses: actions/checkout@v6
with:
repository: ClashKingInc/DevKit
ref: 899b62f64b365a5a861e34178e34ed50c5c52d13
ref: c5fc3ec4db2a6502ee36ac56d8f399b356e4d50e
path: .schema-test
token: ${{ secrets.SCHEMA_REPO_TOKEN || github.token }}

Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clashking/api-workspace",
"version": "0.1.0-rc.26",
"version": "0.1.0-rc.27",
"private": true,
"type": "module",
"workspaces": [
Expand Down
4 changes: 2 additions & 2 deletions packages/api-client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clashking/api-client",
"version": "0.1.0-rc.26",
"version": "0.1.0-rc.27",
"license": "SEE LICENSE IN LICENSE",
"description": "Effect-based transport, auth, service-binding execution, and decoding for the ClashKing v2 API",
"repository": {
Expand All @@ -26,7 +26,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"dependencies": {
"@clashking/api-contracts": "0.1.0-rc.26",
"@clashking/api-contracts": "0.1.0-rc.27",
"effect": "4.0.0-rc.112"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/api-contracts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clashking/api-contracts",
"version": "0.1.0-rc.26",
"version": "0.1.0-rc.27",
"license": "SEE LICENSE IN LICENSE",
"description": "Browser-safe Effect schemas and endpoint metadata for the ClashKing v2 API",
"repository": {
Expand Down
74 changes: 74 additions & 0 deletions packages/api-contracts/src/army-setups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Schema } from "effect"
import { defineEndpoint, NoBody, NoPathParams, type ContractSchema } from "./endpoint.js"
import { ErrorResponse } from "./errors.js"
import { StarCounts, TimeRangeQuery } from "./league-analytics.js"

export const ArmySetupQuery = Schema.Struct({
...TimeRangeQuery.fields,
leagueTierId: Schema.optionalKey(Schema.Number),
rankLimit: Schema.optionalKey(Schema.Number),
groupKey: Schema.optionalKey(Schema.String),
variantKey: Schema.optionalKey(Schema.String),
sort: Schema.optionalKey(Schema.Literals(["usage", "tripleRate"])),
limit: Schema.optionalKey(Schema.Number),
})
export const ArmySetupCondition = Schema.Struct({
kind: Schema.String, id: Schema.Int,
heroId: Schema.optionalKey(Schema.Int),
minimum: Schema.optionalKey(Schema.Int),
})
export const ArmySetupStatistics = Schema.Struct({
groupKey: Schema.String, variantKey: Schema.String,
coreTroops: Schema.Array(Schema.Int), conditions: Schema.Array(ArmySetupCondition),
shareCode: Schema.String, attacks: Schema.Int, starCounts: StarCounts,
usageRate: Schema.NullOr(Schema.Number), threeStarRate: Schema.NullOr(Schema.Number),
averageDestruction: Schema.NullOr(Schema.Number), observedDays: Schema.Int,
sieges: Schema.Array(Schema.Struct({ id: Schema.Int, attacks: Schema.Int, usageRate: Schema.Number })),
dailyRank: Schema.NullOr(Schema.Int), previousDayRank: Schema.NullOr(Schema.Int), rankChange: Schema.NullOr(Schema.Int),
comparisons: Schema.optionalKey(Schema.Array(Schema.Struct({
rankLimit: Schema.NullOr(Schema.Int), attacks: Schema.Int, totalAttacks: Schema.Int,
usageRate: Schema.NullOr(Schema.Number), threeStarRate: Schema.NullOr(Schema.Number),
}))),
})
export const ArmySetupCoverage = Schema.Struct({
firstDay: Schema.String, lastDay: Schema.String,
completedDays: Schema.Array(Schema.String),
totalAttacks: Schema.Int, classifiedAttacks: Schema.Int,
leagueTierId: Schema.Int, rankLimit: Schema.NullOr(Schema.Int),
})
export const ArmySetupResponse = Schema.Struct({
...ArmySetupCoverage.fields,
items: Schema.Array(ArmySetupStatistics),
})
export const ArmySetupTimelineResponse = Schema.Struct({
...ArmySetupCoverage.fields,
benchmarks: Schema.optionalKey(Schema.Array(Schema.Struct({
rankLimit: Schema.NullOr(Schema.Int),
points: Schema.Array(Schema.Struct({ day: Schema.String, attacks: Schema.Int, threeStarRate: Schema.NullOr(Schema.Number) })),
}))),
items: Schema.Array(Schema.Struct({
day: Schema.String, totalAttacks: Schema.Int,
observation: Schema.NullOr(ArmySetupStatistics),
rank: Schema.NullOr(Schema.Int), previousDayRank: Schema.NullOr(Schema.Int), rankChange: Schema.NullOr(Schema.Int),
})),
})
export const ArmySetupRankHistoryResponse = Schema.Struct({
firstDay: Schema.String, lastDay: Schema.String, leagueTierId: Schema.Int,
rankLimit: Schema.NullOr(Schema.Int), groupKey: Schema.String, variantKey: Schema.String,
sort: Schema.Literals(["usage", "tripleRate"]),
points: Schema.Array(Schema.Struct({ day: Schema.String, attacks: Schema.NullOr(Schema.Int),
rank: Schema.NullOr(Schema.Int), previousDayRank: Schema.NullOr(Schema.Int), rankChange: Schema.NullOr(Schema.Int) })),
})
const endpoint = <S extends ContractSchema>(operationId: string, path: `/${string}`, response: S, summary: string) => defineEndpoint({
operationId, path, method: "GET", auth: "public", summary,
body: NoBody, bodyMode: "none", pathParams: NoPathParams,
query: ArmySetupQuery, response, responseMode: "json", successStatus: 200,
errors: [{ status: 400, body: ErrorResponse }],
})
export const ArmySetupsEndpoint = endpoint("armySetups", "/v2/stats/army-setups", ArmySetupResponse,
"Daily troop-overlap overview, or supported setups within a troop group")
export const ArmySetupTimelineEndpoint = endpoint("armySetupTimeline", "/v2/stats/army-setups/timeline", ArmySetupTimelineResponse,
"Daily observations of a troop group or setup; null means no supported observation, not zero usage")
export const ArmySetupRankHistoryEndpoint = endpoint("armySetupRankHistory", "/v2/stats/army-setups/rank-history", ArmySetupRankHistoryResponse,
"Daily selected-cohort rank for a troop group or supported setup")
export const armySetupEndpoints = { armySetups: ArmySetupsEndpoint, armySetupTimeline: ArmySetupTimelineEndpoint, armySetupRankHistory: ArmySetupRankHistoryEndpoint }
2 changes: 1 addition & 1 deletion packages/api-contracts/src/bot-adjacent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const SharedLinksLookupEndpoint = defineEndpoint({

export const CreateServerLinkEndpoint = defineEndpoint({
auth: "server-write", body: Schema.Struct({ playerTag: Schema.String, userID: DecimalSnowflake,
api_token: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))) }),
api_token: Schema.String.check(Schema.isMaxLength(128)) }),
bodyMode: "json", method: "POST", operationId: "createServerLink",
path: "/v2/links/server/:serverId", pathParams: ServerPath, query: NoQuery,
response: LinkMutation, responseMode: "json", successStatus: 200,
Expand Down
6 changes: 4 additions & 2 deletions packages/api-contracts/src/bot-server-overlap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ describe("Bot descriptors sharing an existing Dashboard operation", () => {
expect(bot.successStatus).toBe(canonical.successStatus)
})

it("accepts the canonical linked-account omissions and optional token", () => {
it("accepts canonical linked-account omissions and requires token for new links", () => {
const payload = { items: [{ user_id: "123456789012345678", player_tag: "#P0Y", order_index: 0,
is_verified: false, hidden: false, added_at: "2026-09-04T00:00:00Z" }] }
expect(Schema.decodeUnknownSync(botServer.BotAccountsEndpoint.response)(payload)).toEqual(payload)
expect(Schema.encodeUnknownSync(botServer.BotLinkAccountEndpoint.body)({ player_tag: "#P0Y" })).toEqual({ player_tag: "#P0Y" })
expect(Schema.encodeUnknownSync(botServer.BotLinkAccountEndpoint.body)({ player_tag: "#P0Y", api_token: "valid" }))
.toEqual({ player_tag: "#P0Y", api_token: "valid" })
expect(() => Schema.decodeUnknownSync(botServer.BotLinkAccountEndpoint.body)({ player_tag: "#P0Y" })).toThrow()
})

it("retains the canonical bans actor query", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/api-contracts/src/bot-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const RosterQuestionnaire = Schema.Struct({
})
const RosterViewSort = Schema.Struct({ columnId: Schema.String, direction: Schema.Literals(["asc", "desc"]) })
const RosterSummary = Schema.Struct({
requireVerified: Schema.optionalKey(Schema.Boolean),
minTownhall: Schema.NullOr(Schema.Number),
maxTownhall: Schema.NullOr(Schema.Number),
databaseId: OptionalString,
Expand Down
21 changes: 21 additions & 0 deletions packages/api-contracts/src/cwl-participation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Schema } from "effect"

export const CwlParticipationQuery = Schema.Struct({ season: Schema.optionalKey(Schema.String) })
export const CwlParticipationResponse = Schema.Struct({
season: Schema.NullOr(Schema.String),
availableSeasons: Schema.optionalKey(Schema.Array(Schema.String)),
history: Schema.optionalKey(Schema.Array(Schema.Struct({
season: Schema.String, clanCount: Schema.Int, registeredPlayerCount: Schema.Int, groupCount: Schema.Int,
}))),
clanCount: Schema.Int, registeredPlayerCount: Schema.Int, groupCount: Schema.Int,
items: Schema.Array(Schema.Struct({
leagueId: Schema.Int, warSize: Schema.Int, groupCount: Schema.Int,
clanCount: Schema.Int, registeredPlayerCount: Schema.Int,
townHallDistribution: Schema.Array(Schema.Struct({ level: Schema.Int, count: Schema.Int })),
sameTownHallHitRates: Schema.NullOr(Schema.Array(Schema.Struct({
level: Schema.Int, attacks: Schema.Int, threeStarAttacks: Schema.Int,
threeStarRate: Schema.NullOr(Schema.Number),
}))),
finalizedWars: Schema.Int, archivedWars: Schema.Int, calculatedAt: Schema.String,
})),
})
10 changes: 3 additions & 7 deletions packages/api-contracts/src/dashboard-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,15 +288,11 @@ describe("Dashboard response contracts", () => {
expect(() => decode(DiscordChannel, { id: "1", name: "general", type: 0 })).toThrow()
})

it("requires an explicit boolean in settings responses and accepts only optional boolean updates", () => {
it("omits the retired per-server linking token policy", () => {
const settings = {server_id:"123",server:"123",name:"ClashKing",countdowns:{},server_roles:[]}
for (const value of [false,true]) {
expect(decode(ServerSettings,{...settings,require_api_token_when_linking:value}).require_api_token_when_linking).toBe(value)
expect(decode(ServerSettingsUpdate,{require_api_token_when_linking:value})).toEqual({require_api_token_when_linking:value})
}
expect(decode(ServerSettings,settings)).toEqual(settings)
expect(decode(ServerSettingsUpdate,{})).toEqual({})
for (const value of [null,"false",0]) expect(()=>decode(ServerSettingsUpdate,{require_api_token_when_linking:value})).toThrow()
expect(()=>decode(ServerSettings,settings)).toThrow()
expect(decode(ServerSettingsUpdate,{require_api_token_when_linking:false})).toEqual({})
})

it("rejects a numeric embed color in normalized server settings", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/api-contracts/src/dashboard-misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export const DashboardBillingAssignmentEndpoint = defineEndpoint({

export const DashboardCocAccountRequest = Schema.Struct({
player_tag: Schema.String,
api_token: Schema.optionalKey(Schema.String),
api_token: Schema.String,
})

export const DashboardLinkedPlayer = Schema.Struct({
Expand Down
6 changes: 3 additions & 3 deletions packages/api-contracts/src/dashboard-roster-extra.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import {
} from "./dashboard-roster-extra.js"

describe("canonical roster assistant and signup descriptors", () => {
it("registers eleven unique method/path pairs without fallback descriptors", () => {
it("registers sixteen unique method/path pairs without fallback descriptors", () => {
const endpoints = Object.values(dashboardRosterExtraEndpoints)
expect(endpoints).toHaveLength(11)
expect(new Set(endpoints.map((endpoint) => `${endpoint.method} ${endpoint.path}`)).size).toBe(11)
expect(endpoints).toHaveLength(16)
expect(new Set(endpoints.map((endpoint) => `${endpoint.method} ${endpoint.path}`)).size).toBe(16)
})
it("distinguishes user context from dedicated metering-secret settlement", () => {
expect(DashboardRosterAIContextEndpoint.auth).toBe("user")
Expand Down
44 changes: 43 additions & 1 deletion packages/api-contracts/src/dashboard-roster-extra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,36 @@ const errors = [400, 401, 403, 404, 409, 413, 429, 503].map((status) => ({ statu
const BuilderPath = Schema.Struct({ serverId: DecimalSnowflake, rosterId: Schema.String })
const RosterQuery = Schema.Struct({ server_id: DecimalSnowflake, roster_id: Schema.String })

export const DashboardRosterRefreshPublicationEndpoint = defineEndpoint({
operationId: "dashboardRosterRefreshPublication", method: "POST", path: "/v2/server/:serverId/rosters/:rosterId/refresh-publication",
auth: "server-write", body: Schema.Struct({}), bodyMode: "json", pathParams: BuilderPath, query: NoQuery,
response: Schema.Struct({ updated: Schema.Boolean }), responseMode: "json", successStatus: 200, errors,
summary: "Refresh the newest interactive roster publication",
})

export const DashboardRosterImageEndpoint = defineEndpoint({
operationId: "dashboardRosterImage", method: "POST", path: "/v2/server/:serverId/rosters/:rosterId/image",
auth: "server-write", body: Schema.Unknown, bodyMode: "multipart", pathParams: BuilderPath, query: NoQuery,
response: Schema.Struct({ url: Schema.String, filename: Schema.String }), responseMode: "json",
successStatus: 200, errors, summary: "Upload a roster image",
})

export const DashboardRosterAccountsEndpoint = defineEndpoint({
operationId: "dashboardRosterAccounts", method: "GET", path: "/v2/server/:serverId/rosters/:rosterId/accounts",
auth: "user-or-bot", body: NoBody, bodyMode: "none", pathParams: BuilderPath,
query: Schema.Struct({ discordUserId: Schema.optionalKey(DecimalSnowflake) }),
response: Schema.Struct({ remainingSlots: Schema.NullOr(Schema.Number), items: Schema.Array(Schema.Struct({ tag: Schema.String, name: Schema.String,
townhall: Schema.Number, isVerified: Schema.Boolean, signedUp: Schema.Boolean })) }),
responseMode: "json", successStatus: 200, errors, summary: "List the actor's linked roster accounts",
})
export const DashboardRosterWithdrawEndpoint = defineEndpoint({
operationId: "dashboardRosterWithdraw", method: "POST", path: "/v2/server/:serverId/rosters/:rosterId/withdraw",
auth: "user-or-bot", body: Schema.Struct({ playerTag: Schema.String, discordUserId: Schema.optionalKey(DecimalSnowflake) }),
bodyMode: "json", pathParams: BuilderPath, query: NoQuery,
response: Schema.Struct({ removed: Schema.Boolean }), responseMode: "json", successStatus: 200, errors,
summary: "Withdraw an account owned by the actor from a roster",
})

export const DashboardRosterBatchRequest = Schema.Struct({
serverId: DecimalSnowflake,
rosterIds: Schema.Array(Schema.String),
Expand Down Expand Up @@ -74,7 +104,7 @@ export const DashboardRosterQuestionnaire = Schema.Struct({
})
export const DashboardRosterQuestionnaireEndpoint = defineEndpoint({
operationId: "dashboardRosterQuestionnaire", method: "PUT", path: "/v2/roster/questionnaire",
auth: "server-write", body: Schema.Struct({ questions: Schema.Array(DashboardRosterQuestion) }), bodyMode: "json",
auth: "server-write", body: Schema.Struct({ questions: Schema.Array(DashboardRosterQuestion), reset_answers: Schema.optionalKey(Schema.Boolean) }), bodyMode: "json",
pathParams: NoPathParams, query: RosterQuery,
response: Schema.Struct({ questionnaire: DashboardRosterQuestionnaire, affectedMemberCount: Schema.Number }),
responseMode: "json", successStatus: 200, errors, summary: "Replace roster signup questions",
Expand All @@ -96,6 +126,13 @@ export const DashboardRosterSubmissionEndpoint = defineEndpoint({
response: Schema.Struct({ submission: DashboardRosterSubmission }), responseMode: "json", successStatus: 201,
errors, summary: "Submit linked account roster signup answers",
})
export const DashboardRosterBatchSignupEndpoint = defineEndpoint({
operationId: "dashboardRosterBatchSignup", method: "POST", path: "/v2/server/:serverId/rosters/:rosterId/submissions/batch",
auth: "user-or-bot", body: Schema.Struct({ playerTags: Schema.Array(Schema.String), discordUserId: Schema.optionalKey(DecimalSnowflake) }),
bodyMode: "json", pathParams: BuilderPath, query: NoQuery,
response: Schema.Struct({ signedUpCount: Schema.Number }), responseMode: "json", successStatus: 201,
errors, summary: "Atomically sign up linked accounts to a roster without questions",
})
export const DashboardRosterBuilderMissingMembersEndpoint = defineEndpoint({
operationId: "dashboardRosterBuilderMissingMembers", method: "GET", path: "/v2/server/:serverId/rosters/:rosterId/missing-members",
auth: "server-read", body: NoBody, bodyMode: "none", pathParams: BuilderPath, query: NoQuery,
Expand Down Expand Up @@ -157,6 +194,11 @@ export const DashboardRosterAIUsageEndpoint = defineEndpoint({
})

export const dashboardRosterExtraEndpoints = {
dashboardRosterBatchSignup: DashboardRosterBatchSignupEndpoint,
dashboardRosterRefreshPublication: DashboardRosterRefreshPublicationEndpoint,
dashboardRosterImage: DashboardRosterImageEndpoint,
dashboardRosterAccounts: DashboardRosterAccountsEndpoint,
dashboardRosterWithdraw: DashboardRosterWithdrawEndpoint,
dashboardRosterMembersQuery: DashboardRosterMembersQueryEndpoint,
dashboardRosterAccountGroupsQuery: DashboardRosterAccountGroupsQueryEndpoint,
dashboardRosterRefreshBatch: DashboardRosterRefreshBatchEndpoint,
Expand Down
Loading
Loading