Skip to content

Commit e12e8d6

Browse files
authored
Merge pull request #27 from bitsocialnet/feat/challenge-validate-settings
feat(challenge): add validateChallengeSettings and bump pkc-js to 0.0.85
2 parents f34c83f + 21879cf commit e12e8d6

10 files changed

Lines changed: 2005 additions & 224 deletions

File tree

challenge/README.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,25 @@ await community.edit({
8484

8585
#### Challenge options
8686

87-
All option values must be strings (pkc-js challenge convention).
88-
89-
| Option | Default | Description |
90-
|--------|---------|-------------|
91-
| `chainTicker` | `"base"` | Chain where MintPass contract is deployed |
92-
| `contractAddress` | Known deployment per chain | If omitted and `chainTicker` is supported, defaults to the known MintPass deployment for that chain |
93-
| `requiredTokenType` | `"0"` | Required token type (0=SMS, 1=Email, 2+=future methods) |
94-
| `transferCooldownSeconds` | `"604800"` | Cooldown period after NFT transfer (1 week) |
95-
| `error` | Default message | Custom error message for users without NFT. Use `{authorAddress}` as a placeholder |
87+
All option values must be strings (pkc-js challenge convention). Options are validated on community edit/create/start (pkc-js `>= 0.0.85`): `chainTicker`, `contractAddress` and `requiredTokenType` are required, unknown option keys are rejected, and malformed values fail the edit instead of silently rejecting every author.
88+
89+
| Option | Required | Default | Description |
90+
|--------|----------|---------|-------------|
91+
| `chainTicker` | yes | `"base"` | Chain where MintPass contract is deployed; `"base"` or `"eth"` |
92+
| `contractAddress` | yes | Base Sepolia deployment | MintPass contract address; must be a well-formed EVM address |
93+
| `requiredTokenType` | yes | `"0"` | Required token type (0=SMS, 1=Email, 2+=future methods); integer 0–65535 |
94+
| `bindToFirstAuthor` | no | `"true"` | Bind each tokenId to the first author that uses it in this community (`"true"`/`"false"`/`"1"`/`"0"`) |
95+
| `noChallengeUrl` | no | `"false"` | Fail immediately when the NFT is missing instead of showing the mintpass.org iframe (`"true"`/`"false"`/`"1"`/`"0"`) |
96+
| `transferCooldownSeconds` | no | `"604800"` | Cooldown period after NFT transfer (1 week); non-negative integer |
97+
| `error` | no | Default message | Custom error message for users without NFT. Use `{authorAddress}` as a placeholder |
98+
| `rpcUrl` | no | Chain default | Custom RPC URL (mainly for testing). **Must not** be listed in `publicOptions`; the challenge rejects that since RPC URLs often embed provider API keys |
99+
100+
#### Public options
101+
102+
`publicOptions` controls which options are written into the published community record. Recommended for MintPass:
103+
104+
- Publish `chainTicker`, `contractAddress`, `requiredTokenType` and `noChallengeUrl` so clients can tell authors up front which MintPass they need (and, with `noChallengeUrl`, that no iframe verification will be offered). Publishing `error`, `bindToFirstAuthor` and `transferCooldownSeconds` is harmless and explains rejections; that is the owner's call.
105+
- Never publish `rpcUrl`.
96106

97107
### With bitsocial-cli
98108

challenge/dist/mintpass.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,43 @@ const getChallenge = async ({ challengeSettings, challengeRequestMessage, commun
517517
error: firstFailure || "Failed to verify MintPass"
518518
};
519519
};
520+
// Boolean-like option values accepted by getChallenge ("true"/"1" are truthy, everything else is falsy).
521+
// Anything outside this set is almost certainly a typo that would silently read as false.
522+
const BOOLEAN_OPTION_VALUES = new Set(["true", "false", "1", "0"]);
523+
// Tickers that _getChainProviderWithSafety / createViemClientForChain can actually resolve.
524+
const SUPPORTED_CHAIN_TICKERS = new Set(["eth", "base"]);
525+
const MAX_TOKEN_TYPE = 65535; // tokenType is a uint16 in the MintPass ABI
526+
const isNonNegativeIntegerString = (value) => /^\d+$/.test(value) && Number.isSafeInteger(Number(value));
527+
/**
528+
* Semantic validation of challenge settings, run by pkc-js on community edit/create/start.
529+
* Must stay sync and network-free. Presence of required options is enforced by pkc-js core
530+
* via optionInputs; this hook checks that the values actually parse.
531+
*/
532+
const validateChallengeSettings = ({ challengeSettings }) => {
533+
const options = challengeSettings.options || {};
534+
const { chainTicker, contractAddress, requiredTokenType, transferCooldownSeconds, bindToFirstAuthor, noChallengeUrl } = options;
535+
if (chainTicker !== undefined && !SUPPORTED_CHAIN_TICKERS.has(chainTicker)) {
536+
throw Error(`Invalid option chainTicker "${chainTicker}": must be one of ${[...SUPPORTED_CHAIN_TICKERS].map((t) => `"${t}"`).join(", ")}`);
537+
}
538+
if (contractAddress !== undefined && !isAddress(contractAddress)) {
539+
throw Error(`Invalid option contractAddress "${contractAddress}": must be a well-formed EVM address`);
540+
}
541+
if (requiredTokenType !== undefined && (!isNonNegativeIntegerString(requiredTokenType) || Number(requiredTokenType) > MAX_TOKEN_TYPE)) {
542+
throw Error(`Invalid option requiredTokenType "${requiredTokenType}": must be an integer between 0 and ${MAX_TOKEN_TYPE}`);
543+
}
544+
if (transferCooldownSeconds !== undefined && !isNonNegativeIntegerString(transferCooldownSeconds)) {
545+
throw Error(`Invalid option transferCooldownSeconds "${transferCooldownSeconds}": must be a non-negative integer`);
546+
}
547+
for (const [name, value] of [["bindToFirstAuthor", bindToFirstAuthor], ["noChallengeUrl", noChallengeUrl]]) {
548+
if (value !== undefined && !BOOLEAN_OPTION_VALUES.has(value.toLowerCase())) {
549+
throw Error(`Invalid option ${name} "${value}": must be one of "true", "false", "1", "0"`);
550+
}
551+
}
552+
// rpcUrl commonly embeds a provider API key and is never needed by clients; refuse to publish it.
553+
if (challengeSettings.publicOptions?.includes("rpcUrl")) {
554+
throw Error('Option rpcUrl must not be in publicOptions: it may contain RPC provider credentials');
555+
}
556+
};
520557
/**
521558
* Challenge file factory function
522559
*/
@@ -526,7 +563,8 @@ function ChallengeFileFactory({ challengeSettings }) {
526563
getChallenge,
527564
optionInputs,
528565
type,
529-
description
566+
description,
567+
validateChallengeSettings
530568
};
531569
}
532570
export default ChallengeFileFactory;

challenge/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bitsocial/mintpass-challenge",
3-
"version": "1.4.1",
3+
"version": "1.5.0",
44
"description": "MintPass pkc-js challenge implementation",
55
"type": "module",
66
"main": "dist/index.js",
@@ -23,7 +23,8 @@
2323
},
2424
"scripts": {
2525
"build": "tsc",
26-
"test": "corepack yarn build && ./scripts/test-with-node.sh",
26+
"test": "corepack yarn test:unit && ./scripts/test-with-node.sh",
27+
"test:unit": "corepack yarn build && node --test test/validate-challenge-settings.test.js",
2728
"test:manual": "corepack yarn build && cd ../contracts && corepack yarn hardhat test ../challenge/test/mintpass-integration.test.js --network localhost",
2829
"clean": "rm -rf dist/",
2930
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
@@ -39,7 +40,7 @@
3940
"viem": "2.45.0"
4041
},
4142
"devDependencies": {
42-
"@pkcprotocol/pkc-js": "0.0.16",
43+
"@pkcprotocol/pkc-js": "0.0.85",
4344
"@types/chai": "^4.2.0",
4445
"@types/mocha": "^10.0.10",
4546
"@types/node": "^22.7.8",

challenge/src/mintpass.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,47 @@ const getChallenge = async ({
643643
} as ChallengeResultInput;
644644
};
645645

646+
// Boolean-like option values accepted by getChallenge ("true"/"1" are truthy, everything else is falsy).
647+
// Anything outside this set is almost certainly a typo that would silently read as false.
648+
const BOOLEAN_OPTION_VALUES = new Set(["true", "false", "1", "0"]);
649+
// Tickers that _getChainProviderWithSafety / createViemClientForChain can actually resolve.
650+
const SUPPORTED_CHAIN_TICKERS = new Set(["eth", "base"]);
651+
const MAX_TOKEN_TYPE = 65535; // tokenType is a uint16 in the MintPass ABI
652+
const isNonNegativeIntegerString = (value: string) => /^\d+$/.test(value) && Number.isSafeInteger(Number(value));
653+
654+
/**
655+
* Semantic validation of challenge settings, run by pkc-js on community edit/create/start.
656+
* Must stay sync and network-free. Presence of required options is enforced by pkc-js core
657+
* via optionInputs; this hook checks that the values actually parse.
658+
*/
659+
const validateChallengeSettings: NonNullable<ChallengeFileInput["validateChallengeSettings"]> = ({ challengeSettings }) => {
660+
const options = challengeSettings.options || {};
661+
const { chainTicker, contractAddress, requiredTokenType, transferCooldownSeconds, bindToFirstAuthor, noChallengeUrl } = options;
662+
663+
if (chainTicker !== undefined && !SUPPORTED_CHAIN_TICKERS.has(chainTicker)) {
664+
throw Error(`Invalid option chainTicker "${chainTicker}": must be one of ${[...SUPPORTED_CHAIN_TICKERS].map((t) => `"${t}"`).join(", ")}`);
665+
}
666+
if (contractAddress !== undefined && !isAddress(contractAddress)) {
667+
throw Error(`Invalid option contractAddress "${contractAddress}": must be a well-formed EVM address`);
668+
}
669+
if (requiredTokenType !== undefined && (!isNonNegativeIntegerString(requiredTokenType) || Number(requiredTokenType) > MAX_TOKEN_TYPE)) {
670+
throw Error(`Invalid option requiredTokenType "${requiredTokenType}": must be an integer between 0 and ${MAX_TOKEN_TYPE}`);
671+
}
672+
if (transferCooldownSeconds !== undefined && !isNonNegativeIntegerString(transferCooldownSeconds)) {
673+
throw Error(`Invalid option transferCooldownSeconds "${transferCooldownSeconds}": must be a non-negative integer`);
674+
}
675+
for (const [name, value] of [["bindToFirstAuthor", bindToFirstAuthor], ["noChallengeUrl", noChallengeUrl]] as const) {
676+
if (value !== undefined && !BOOLEAN_OPTION_VALUES.has(value.toLowerCase())) {
677+
throw Error(`Invalid option ${name} "${value}": must be one of "true", "false", "1", "0"`);
678+
}
679+
}
680+
681+
// rpcUrl commonly embeds a provider API key and is never needed by clients; refuse to publish it.
682+
if (challengeSettings.publicOptions?.includes("rpcUrl")) {
683+
throw Error('Option rpcUrl must not be in publicOptions: it may contain RPC provider credentials');
684+
}
685+
};
686+
646687
/**
647688
* Challenge file factory function
648689
*/
@@ -653,7 +694,8 @@ function ChallengeFileFactory({ challengeSettings }: { challengeSettings: Commun
653694
getChallenge,
654695
optionInputs,
655696
type,
656-
description
697+
description,
698+
validateChallengeSettings
657699
};
658700
}
659701

0 commit comments

Comments
 (0)